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:
Claude
2026-08-30 15:09:46 +00:00
parent a549bb065a
commit 9298b4d142
6 changed files with 1973 additions and 0 deletions
+474
View File
@@ -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();
},
};
}