Files
ProdDash/modules/propresenter-now-next/client.js
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00

505 lines
18 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 }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
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');
if (nowOnly) screenEl.classList.add('nn-only-now');
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 nowLabelEl = root.querySelector('.nn-label');
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');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() {
return moduleApi.instanceSettings;
}
const on = (key) => prefs()[key] !== false;
/* ── 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 on('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 (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else 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 (on('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 (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
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 || !on('showNextLabel');
nextMoreEl.hidden = true;
markNextOverflow();
requestAnimationFrame(markNextOverflow);
}
function render() {
screenEl.classList.toggle('is-offline', feedOffline);
// Trouble always shows, whatever is switched off: a tile that has stopped
// updating must never look like one with nothing live.
const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive();
renderNow();
if (!nowOnly && on('showNext')) 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');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
}
/* ── 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();
},
};
}