propresenter-now-next module: Now/Next tile fed by propresenter-core
Milestone 4. propresenter-core copied in unchanged (single home for every ProPresenter 7/20 quirk). Server part owns the one polling connection and exposes GET /state plus GET /stream (SSE, change-detected, heartbeated); health reported for the admin page. Client ports the wall display's rendering: fill-the-box title fit that never breaks words, tidied titles, slide meta + progress bar, NEXT list with hidden-row '+ N more' overflow, playlist name and live chip, all scaled to the tile via a JS-set --unit. Verified against pp-mock.js including the arrangement-repeat case (the counter follows core's honest-live-index rule, never X > Y) and kill-and-restart recovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -0,0 +1,474 @@
|
|||||||
|
/* ProPresenter Now / Next — client part.
|
||||||
|
|
||||||
|
Ported from band-lineup-display/propresenter-app/public/app.js into the
|
||||||
|
ProdDash module contract: the server part does all the ProPresenter
|
||||||
|
talking and streams the view-model here over SSE. Connection setup lives
|
||||||
|
in /admin; the tile only renders. */
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let feedOffline = false;
|
||||||
|
let stream = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
|
||||||
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="nn-screen">
|
||||||
|
<header class="nn-top">
|
||||||
|
<div class="nn-playlist"></div>
|
||||||
|
<div class="nn-live"><span class="nn-live-dot"></span><span class="nn-live-text">Connecting…</span></div>
|
||||||
|
</header>
|
||||||
|
<section class="nn-now">
|
||||||
|
<div class="nn-label">Now</div>
|
||||||
|
<div class="nn-title-box"><div class="nn-title is-waiting">Connecting…</div></div>
|
||||||
|
<div class="nn-meta"></div>
|
||||||
|
<div class="nn-progress" hidden><div class="nn-progress-fill"></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="nn-next-wrap">
|
||||||
|
<div class="nn-next-label" hidden>Next</div>
|
||||||
|
<div class="nn-next-list"></div>
|
||||||
|
<div class="nn-next-more" hidden></div>
|
||||||
|
</section>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const screenEl = root.querySelector('.nn-screen');
|
||||||
|
const topRow = root.querySelector('.nn-top');
|
||||||
|
const playlistNameEl = root.querySelector('.nn-playlist');
|
||||||
|
const liveEl = root.querySelector('.nn-live');
|
||||||
|
const liveTextEl = root.querySelector('.nn-live-text');
|
||||||
|
const nowEl = root.querySelector('.nn-now');
|
||||||
|
const nowTitleBox = root.querySelector('.nn-title-box');
|
||||||
|
let nowTitleEl = root.querySelector('.nn-title');
|
||||||
|
const nowMetaEl = root.querySelector('.nn-meta');
|
||||||
|
const progressEl = root.querySelector('.nn-progress');
|
||||||
|
const progressFillEl = root.querySelector('.nn-progress-fill');
|
||||||
|
const nextLabelEl = root.querySelector('.nn-next-label');
|
||||||
|
const nextListEl = root.querySelector('.nn-next-list');
|
||||||
|
const nextMoreEl = root.querySelector('.nn-next-more');
|
||||||
|
|
||||||
|
function prefs() {
|
||||||
|
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
|
||||||
|
|
||||||
|
function cleanItemName(raw) {
|
||||||
|
const original = String(raw || '').trim();
|
||||||
|
let s = original;
|
||||||
|
s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags
|
||||||
|
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code
|
||||||
|
s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators
|
||||||
|
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space
|
||||||
|
s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace
|
||||||
|
return s || original; // never blank out a title (e.g. a name that was only a tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(raw) {
|
||||||
|
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTypeTag(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'media': return 'Media';
|
||||||
|
case 'audio': return 'Audio';
|
||||||
|
case 'livevideo': return 'Live';
|
||||||
|
case 'placeholder': return 'PCO';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── rendering ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** The items after the live one, hidden entries dropped. */
|
||||||
|
function upcomingItems() {
|
||||||
|
const items = Array.isArray(state?.items) ? state.items : [];
|
||||||
|
// Presenting from the playlist → everything after the live item.
|
||||||
|
// Presenting from the Library (activeIndex < 0) → the whole list is still ahead.
|
||||||
|
const from = state && state.activeIndex >= 0 ? state.activeIndex + 1 : 0;
|
||||||
|
const out = [];
|
||||||
|
for (let i = from; i < items.length; i += 1) {
|
||||||
|
if (!items[i].isHidden) out.push(items[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the browser a brand-new title element whenever the text changes —
|
||||||
|
* on some GPUs a large text element keeps a stale compositor backing
|
||||||
|
* store, ghosting the previous song under the new one. A fresh node has
|
||||||
|
* no old backing store. (Carried over from the wall display.)
|
||||||
|
*/
|
||||||
|
let lastTitleText = null;
|
||||||
|
function setNowTitle(text) {
|
||||||
|
if (text === lastTitleText && nowTitleEl.isConnected) return;
|
||||||
|
lastTitleText = text;
|
||||||
|
const fresh = document.createElement('div');
|
||||||
|
fresh.className = 'nn-title';
|
||||||
|
fresh.textContent = text;
|
||||||
|
nowTitleEl.replaceWith(fresh);
|
||||||
|
nowTitleEl = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size the NOW title as large as fits without ever breaking a word:
|
||||||
|
* with word-breaking forbidden, an over-wide word overflows and the same
|
||||||
|
* fit test that guards height rejects that size too; binary search lands
|
||||||
|
* on the biggest size where whole words fit. Hysteresis stops ±1px flap.
|
||||||
|
*/
|
||||||
|
function fitNowTitle() {
|
||||||
|
if (nowTitleEl.classList.contains('is-waiting')) return;
|
||||||
|
const boxHeight = nowTitleBox.clientHeight;
|
||||||
|
const boxWidth = nowTitleBox.clientWidth;
|
||||||
|
const MIN = 11;
|
||||||
|
|
||||||
|
nowTitleEl.style.whiteSpace = 'normal';
|
||||||
|
nowTitleEl.style.overflowWrap = 'normal';
|
||||||
|
nowTitleEl.style.wordBreak = 'normal';
|
||||||
|
nowTitleEl.style.hyphens = 'none';
|
||||||
|
|
||||||
|
if (boxHeight < MIN || !boxWidth) {
|
||||||
|
nowTitleEl.style.fontSize = `${MIN}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fits = (size) => {
|
||||||
|
nowTitleEl.style.fontSize = `${size}px`;
|
||||||
|
return nowTitleEl.scrollWidth <= nowTitleBox.clientWidth + 1
|
||||||
|
&& nowTitleEl.scrollHeight <= nowTitleBox.clientHeight + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const max = Math.max(MIN, Math.floor(boxHeight));
|
||||||
|
let best = MIN;
|
||||||
|
if (fits(max)) {
|
||||||
|
best = max;
|
||||||
|
} else {
|
||||||
|
let lo = MIN;
|
||||||
|
let hi = max;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentSize = parseFloat(nowTitleEl.style.fontSize) || 0;
|
||||||
|
// Hold the current size only if it still genuinely fits (a plain ±1px
|
||||||
|
// band can straddle the line-wrap threshold and stick on the
|
||||||
|
// overflowing side).
|
||||||
|
if (currentSize && Math.abs(currentSize - best) <= 1 && fits(currentSize)) {
|
||||||
|
nowTitleEl.style.fontSize = `${currentSize}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nowTitleEl.style.fontSize = `${best}px`;
|
||||||
|
|
||||||
|
// Last resort: one word so long it can't fit even at MIN — allow it to
|
||||||
|
// break rather than overflow the card.
|
||||||
|
if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) {
|
||||||
|
nowTitleEl.style.overflowWrap = 'anywhere';
|
||||||
|
nowTitleEl.style.wordBreak = 'break-word';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the NEXT rows past the bottom of the list and count them into
|
||||||
|
* "+ N more". Rows are hidden, never removed, so the list's height stays
|
||||||
|
* fixed and the layout can't ratchet.
|
||||||
|
*/
|
||||||
|
function markNextOverflow() {
|
||||||
|
const rows = Array.from(nextListEl.children);
|
||||||
|
for (const row of rows) row.style.visibility = '';
|
||||||
|
let hidden = 0;
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
const listRect = nextListEl.getBoundingClientRect();
|
||||||
|
if (listRect.height <= 0) return;
|
||||||
|
let changed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.style.visibility === 'hidden') continue;
|
||||||
|
if (row.getBoundingClientRect().bottom > listRect.bottom + 1) {
|
||||||
|
row.style.visibility = 'hidden';
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hidden += changed;
|
||||||
|
if (hidden > 0) {
|
||||||
|
nextMoreEl.hidden = false;
|
||||||
|
nextMoreEl.textContent = `+ ${hidden} more`;
|
||||||
|
}
|
||||||
|
if (changed === 0) break;
|
||||||
|
}
|
||||||
|
if (hidden === 0) {
|
||||||
|
nextMoreEl.hidden = true;
|
||||||
|
nextMoreEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLive() {
|
||||||
|
liveEl.classList.remove('is-live', 'is-down');
|
||||||
|
if (feedOffline) {
|
||||||
|
liveEl.classList.add('is-down');
|
||||||
|
liveTextEl.textContent = 'Feed offline';
|
||||||
|
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
liveTextEl.textContent = 'Connecting…';
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.enabled) {
|
||||||
|
liveTextEl.textContent = 'Not set up';
|
||||||
|
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.reachable) {
|
||||||
|
liveEl.classList.add('is-live');
|
||||||
|
liveTextEl.textContent = 'Live';
|
||||||
|
moduleApi.setStatus('ok', 'Following ProPresenter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
liveEl.classList.add('is-down');
|
||||||
|
liveTextEl.textContent = 'Reconnecting…';
|
||||||
|
moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNow() {
|
||||||
|
const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null;
|
||||||
|
const rawName = activeItem?.name || state?.activePresentationName || '';
|
||||||
|
|
||||||
|
for (const stale of nowEl.querySelectorAll('.nn-hint, .nn-stale')) stale.remove();
|
||||||
|
|
||||||
|
if (feedOffline) {
|
||||||
|
setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Feed offline');
|
||||||
|
nowTitleEl.classList.add('is-waiting');
|
||||||
|
nowTitleEl.style.fontSize = '';
|
||||||
|
nowMetaEl.textContent = '';
|
||||||
|
progressEl.hidden = true;
|
||||||
|
const staleEl = document.createElement('div');
|
||||||
|
staleEl.className = 'nn-stale';
|
||||||
|
staleEl.textContent = 'Not receiving updates from the ProdDash server.';
|
||||||
|
nowEl.appendChild(staleEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawName) {
|
||||||
|
// A status message is not a song title — keep its CSS size instead of
|
||||||
|
// the fill-the-box fit.
|
||||||
|
let hint = '';
|
||||||
|
if (!state?.enabled) {
|
||||||
|
setNowTitle('No ProPresenter connection');
|
||||||
|
hint = 'Set the ProPresenter host and port in Admin.';
|
||||||
|
} else {
|
||||||
|
// Connected with nothing live is normal (output cleared) — say it
|
||||||
|
// plainly so nobody goes hunting for a broken connection.
|
||||||
|
setNowTitle(state?.reachable ? 'Nothing on screen' : 'Reconnecting…');
|
||||||
|
hint = state?.reachable
|
||||||
|
? 'Connected to ProPresenter — waiting for a slide to go live.'
|
||||||
|
: 'Trying to reach ProPresenter.';
|
||||||
|
}
|
||||||
|
nowTitleEl.classList.add('is-waiting');
|
||||||
|
nowTitleEl.style.fontSize = '';
|
||||||
|
nowMetaEl.textContent = '';
|
||||||
|
progressEl.hidden = true;
|
||||||
|
if (hint) {
|
||||||
|
const hintEl = document.createElement('div');
|
||||||
|
hintEl.className = 'nn-hint';
|
||||||
|
hintEl.textContent = hint;
|
||||||
|
nowEl.appendChild(hintEl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNowTitle(displayName(rawName));
|
||||||
|
nowTitleEl.classList.remove('is-waiting');
|
||||||
|
fitNowTitle();
|
||||||
|
// Re-fit after layout settles: on the first render the box may not have
|
||||||
|
// its final height, and a too-tall fit would overflow for a frame.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// An empty NEXT list looks broken unless the tile says why it is empty.
|
||||||
|
if (!upcomingItems().length) {
|
||||||
|
const kind = state?.playlistKind;
|
||||||
|
let why = '';
|
||||||
|
if (kind === 'group' || kind === 'none') {
|
||||||
|
why = 'Presenting from the library — no playlist open.';
|
||||||
|
} else if (kind === 'unknown') {
|
||||||
|
why = 'ProPresenter did not return this playlist’s items.';
|
||||||
|
} else if (Array.isArray(state?.items) && state.items.length) {
|
||||||
|
why = 'Last item in the playlist.';
|
||||||
|
}
|
||||||
|
if (why) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'nn-hint';
|
||||||
|
note.textContent = why;
|
||||||
|
nowEl.appendChild(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
|
||||||
|
const current = state.currentSlide || 0;
|
||||||
|
if (total > 0 && current > 0) {
|
||||||
|
const remaining = Math.max(0, total - current);
|
||||||
|
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
|
||||||
|
} else if (total > 0) {
|
||||||
|
nowMetaEl.textContent = `${total} slides`;
|
||||||
|
} else if (current > 0) {
|
||||||
|
nowMetaEl.textContent = `Slide ${current}`;
|
||||||
|
} else {
|
||||||
|
nowMetaEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs().showProgress && total > 0) {
|
||||||
|
progressEl.hidden = false;
|
||||||
|
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
|
||||||
|
progressFillEl.style.width = `${pct}%`;
|
||||||
|
} else {
|
||||||
|
progressEl.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNext() {
|
||||||
|
nextListEl.innerHTML = '';
|
||||||
|
const upcoming = upcomingItems();
|
||||||
|
screenEl.classList.toggle('no-upcoming', upcoming.length === 0);
|
||||||
|
|
||||||
|
for (const item of upcoming) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const nameEl = document.createElement('div');
|
||||||
|
nameEl.className = 'nn-next-name';
|
||||||
|
|
||||||
|
if (item.type === 'header') {
|
||||||
|
row.className = 'nn-next-item is-header';
|
||||||
|
nameEl.textContent = displayName(item.name || '');
|
||||||
|
row.appendChild(nameEl);
|
||||||
|
nextListEl.appendChild(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.className = 'nn-next-item';
|
||||||
|
nameEl.textContent = displayName(item.name) || '(untitled)';
|
||||||
|
row.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'nn-count-chip';
|
||||||
|
chip.textContent = String(item.slideCount);
|
||||||
|
row.appendChild(chip);
|
||||||
|
} else {
|
||||||
|
const tag = itemTypeTag(item.type);
|
||||||
|
if (tag) {
|
||||||
|
const tagEl = document.createElement('div');
|
||||||
|
tagEl.className = 'nn-type-tag';
|
||||||
|
tagEl.textContent = tag;
|
||||||
|
row.appendChild(tagEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextListEl.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLabelEl.hidden = upcoming.length === 0;
|
||||||
|
nextMoreEl.hidden = true;
|
||||||
|
markNextOverflow();
|
||||||
|
requestAnimationFrame(markNextOverflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
screenEl.classList.toggle('is-offline', feedOffline);
|
||||||
|
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
|
||||||
|
topRow.classList.toggle('hidden-row', !showTop);
|
||||||
|
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
|
||||||
|
renderLive();
|
||||||
|
renderNow();
|
||||||
|
renderNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sizing: --unit scales the whole card to the tile ───────────── */
|
||||||
|
|
||||||
|
function applyUnit() {
|
||||||
|
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
|
||||||
|
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
// EventSource retries by itself (and the shell recreates closed
|
||||||
|
// streams), but the tile must admit it is frozen meanwhile.
|
||||||
|
feedOffline = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try {
|
||||||
|
state = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
applyUnit();
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
// Instant paint from the snapshot, then live updates.
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (body && body.state && !state) {
|
||||||
|
state = body.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* the stream will cover it */ });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Re-fit on any size change; guard against reacting to our own DOM
|
||||||
|
// writes by only re-rendering when a watched box actually changed.
|
||||||
|
let lastW = 0;
|
||||||
|
let lastH = 0;
|
||||||
|
let pending = false;
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
const w = Math.round(root.clientWidth);
|
||||||
|
const h = Math.round(root.clientHeight);
|
||||||
|
if (w === lastW && h === lastH) return;
|
||||||
|
lastW = w;
|
||||||
|
lastH = h;
|
||||||
|
if (pending) return;
|
||||||
|
pending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pending = false;
|
||||||
|
applyUnit();
|
||||||
|
fitNowTitle();
|
||||||
|
markNextOverflow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
resizeObserver.observe(root);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
onResize() {
|
||||||
|
applyUnit();
|
||||||
|
fitNowTitle();
|
||||||
|
markNextOverflow();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "propresenter-now-next",
|
||||||
|
"name": "ProPresenter Now / Next",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 3, "h": 3 },
|
||||||
|
"defaultSize": { "w": 4, "h": 5 },
|
||||||
|
"configSchema": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "ProPresenter host / IP",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "ProPresenter network port",
|
||||||
|
"default": 1025
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "API password (only if set in ProPresenter)",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
|
||||||
|
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
|
||||||
|
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "propresenter-core",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProPresenter Now / Next — server part.
|
||||||
|
*
|
||||||
|
* Owns the one ProPresenter connection (via propresenter-core, copied here
|
||||||
|
* unchanged — it holds every ProPresenter 7/20 quirk) and fans the live
|
||||||
|
* view-model out to every tile over SSE. Browsers never talk to
|
||||||
|
* ProPresenter directly.
|
||||||
|
*
|
||||||
|
* GET /state → { state: <view-model> } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
|
||||||
|
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const enabled = Boolean(config.host && Number(config.port));
|
||||||
|
let latest = clearedViewModel();
|
||||||
|
let lastFrame = '';
|
||||||
|
|
||||||
|
const client = createProPresenterClient({
|
||||||
|
onData(view) {
|
||||||
|
latest = view;
|
||||||
|
if (!streams.size) return;
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.stringify(view);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The poll loop emits every tick whether anything changed or not.
|
||||||
|
// Broadcasting only real changes keeps idle tiles genuinely idle.
|
||||||
|
if (frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.configure({
|
||||||
|
enabled,
|
||||||
|
host: String(config.host || ''),
|
||||||
|
port: Number(config.port) || 1025,
|
||||||
|
password: String(config.password || ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
client.start();
|
||||||
|
log(`following ProPresenter at ${config.host}:${config.port}`);
|
||||||
|
} else {
|
||||||
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = { streams, getLatest: () => latest };
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
client.stop();
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
|
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: latest.lastError
|
||||||
|
? `ProPresenter unreachable: ${latest.lastError}`
|
||||||
|
: 'Trying to reach ProPresenter…',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() }));
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
const view = current ? current.getLatest() : clearedViewModel();
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||||
|
// not queue frames forever; keepalive probes make the OS notice.
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/* ProPresenter Now / Next module — the wall display's visual language,
|
||||||
|
scaled to a tile. All sizes hang off --unit (set from the tile height by
|
||||||
|
client.js); all colors come from the shell's variables. */
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen {
|
||||||
|
--unit: 12px;
|
||||||
|
--nn-pad: calc(var(--unit) * 1.1);
|
||||||
|
--nn-gap: calc(var(--unit) * 0.7);
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nn-gap);
|
||||||
|
padding: var(--nn-pad);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* With no NEXT list there is nothing below the card — centre it. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen.no-upcoming {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top row ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nn-gap);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-top.hidden-row { display: none; }
|
||||||
|
|
||||||
|
/* Losing the feed is the one thing the tile MUST admit to — overrides the
|
||||||
|
"hide the top row" preference. */
|
||||||
|
[data-module="propresenter-now-next"] .is-offline .nn-top,
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen.is-offline .nn-top {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .is-offline .nn-live-text {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .is-offline .nn-now {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-playlist {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: calc(var(--unit) * 1.0);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-live {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--unit) * 0.45);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: calc(var(--unit) * 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-live-dot {
|
||||||
|
width: calc(var(--unit) * 0.6);
|
||||||
|
height: calc(var(--unit) * 0.6);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-dot {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-live.is-down .nn-live-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--danger) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle tiles don't need a status chip staring at them — it fades once live. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-text {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.6s ease 1.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NOW card ──
|
||||||
|
FIXED height so the title box is deterministic and the NOW/NEXT layout
|
||||||
|
cannot oscillate (see the wall display's notes). */
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-now {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 42%;
|
||||||
|
min-height: calc(var(--unit) * 7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--unit) * 0.9);
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
color-mix(in srgb, var(--accent) 9%, transparent),
|
||||||
|
color-mix(in srgb, var(--bg) 60%, transparent)
|
||||||
|
);
|
||||||
|
padding: calc(var(--nn-pad) * 0.8) var(--nn-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: calc(var(--unit) * 0.9);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-title-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: calc(var(--unit) * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-title {
|
||||||
|
width: 100%;
|
||||||
|
/* Font size is set by fitNowTitle() so the title fills the box; keep
|
||||||
|
line-height ≥ the font's natural ~1.2 or large ascenders/descenders clip. */
|
||||||
|
font-size: calc(var(--unit) * 2.4);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
/* Wrapping is controlled by fitNowTitle(), which forbids mid-word breaks. */
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text, not a title: readable size, never the fill-the-box treatment. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-title.is-waiting {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: calc(var(--unit) * 1.5);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-hint {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: calc(var(--unit) * 0.4);
|
||||||
|
font-size: calc(var(--unit) * 0.95);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-stale {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: calc(var(--unit) * 0.4);
|
||||||
|
font-size: calc(var(--unit) * 0.95);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: calc(var(--unit) * 0.45);
|
||||||
|
font-size: calc(var(--unit) * 1.15);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.85;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
/* Never wrap: a wrap changes the title box height, which refits the title. */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-meta:empty { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-progress {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: calc(var(--unit) * 0.55);
|
||||||
|
height: calc(var(--unit) * 0.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-progress[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 65%, var(--panel));
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NEXT list ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--unit) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: calc(var(--unit) * 0.9);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-label[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--unit) * 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nn-gap);
|
||||||
|
padding: calc(var(--unit) * 0.6) calc(var(--unit) * 0.85);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--unit) * 0.6);
|
||||||
|
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: calc(var(--unit) * 1.25);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-count-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: calc(var(--unit) * 2);
|
||||||
|
padding: calc(var(--unit) * 0.15) calc(var(--unit) * 0.5);
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: calc(var(--unit) * 0.95);
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-type-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: calc(var(--unit) * 0.8);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-item.is-header {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: calc(var(--unit) * 0.55) calc(var(--unit) * 0.1) calc(var(--unit) * 0.2);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-item.is-header .nn-next-name {
|
||||||
|
font-size: calc(var(--unit) * 0.9);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: calc(var(--unit) * 0.9);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-next-more[hidden] { display: none; }
|
||||||
Reference in New Issue
Block a user