Files
ProdDash/modules/propresenter-now-next/client.js
Claude 9298b4d142 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
2026-08-30 15:09:46 +00:00

475 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();
},
};
}