ProdDash: new project with reference forks of ProdCom Listener and BandLineupDisplay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-08-30 10:09:41 -04:00
co-authored by Claude Fable 5
commit 150797389e
45 changed files with 18299 additions and 0 deletions
@@ -0,0 +1,830 @@
'use strict';
/* Stage Now / Next — display client.
Receives the live view-model over SSE from server.js (which does all the
ProPresenter talking) and renders it. Also owns the settings panel: the
connection lives on the server and is shared by every screen, while the
look-and-feel options are per-device in localStorage. */
const el = (id) => document.getElementById(id);
const screenEl = el('screen');
const topRow = el('top');
const playlistNameEl = el('playlist-name');
const liveEl = el('live');
const liveTextEl = el('live-text');
const nowTitleBox = el('now-title-box');
let nowTitleEl = el('now-title');
const nowMetaEl = el('now-meta');
const progressEl = el('progress');
const progressFillEl = el('progress-fill');
const nextLabelEl = el('next-label');
const nextListEl = el('next-list');
const nextMoreEl = el('next-more');
const gearBtn = el('gear');
const panel = el('panel');
const panelBackdrop = el('panel-backdrop');
const panelClose = el('panel-close');
const panelStatus = el('panel-status');
const hostInput = el('host');
const portInput = el('port');
const passwordInput = el('password');
const btnConnect = el('btn-connect');
const btnDisconnect = el('btn-disconnect');
const btnFullscreen = el('btn-fullscreen');
const scaleRow = el('scale-row');
const optPlaylist = el('opt-playlist');
const optProgress = el('opt-progress');
const optClean = el('opt-clean');
const urlList = el('url-list');
const aboutLine = el('about-line');
const connectionSection = el('connection-section');
const managedSection = el('managed-section');
const managedNote = el('managed-note');
const PREFS_KEY = 'ppDisplay:prefs';
/**
* How long the settings gear stays visible after the last pointer/key activity.
* On a wall tablet the first tap wakes the gear and the second opens it, so this
* needs to be long enough for someone to reach across and press it.
*/
const CHROME_IDLE_MS = 6000;
let state = null;
let status = null;
let chromeTimer = null;
let lastErrorShown = '';
/** The live SSE connection, retained so it can be torn down and re-opened on demand. */
let stream = null;
/** True while we have no feed from the host at all (as opposed to ProPresenter being down). */
let feedOffline = false;
/** Host build this page was loaded from. When it changes, the host was updated → reload. */
let loadedHostVersion = null;
/* ---------------- per-device preferences ---------------- */
const defaultPrefs = { scale: 1, showPlaylist: true, showProgress: true, cleanTitles: true };
let prefs = { ...defaultPrefs };
function loadPrefs() {
try {
const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}');
prefs = { ...defaultPrefs, ...saved };
} catch {
prefs = { ...defaultPrefs };
}
}
function savePrefs() {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
/* private mode / storage full — the display still works, just won't remember */
}
}
function applyPrefs() {
document.documentElement.style.setProperty('--scale', String(prefs.scale));
for (const chip of scaleRow.querySelectorAll('.chip')) {
chip.classList.toggle('is-on', Number(chip.dataset.scale) === Number(prefs.scale));
}
optPlaylist.checked = prefs.showPlaylist;
optProgress.checked = prefs.showProgress;
optClean.checked = prefs.cleanTitles;
render();
}
/* ---------------- title tidying ----------------
Same rule as the Band Lineup app's Now/Next column. Duplicated here on purpose:
this page loads as a plain script and can't require ../propresenter-core, and the
raw name is still what the server matches ProPresenter items on. */
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. Shared by NOW's note and NEXT. */
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;
}
/**
* Size the NOW title as large as it can be while (a) staying inside the box and (b) NEVER
* breaking a word across lines. Wrapping is allowed only at spaces.
*
* The trick that makes it robust: with word-breaking forbidden (overflow-wrap/word-break
* normal), a word wider than the box OVERFLOWS rather than splitting, so scrollWidth
* exceeds the box — and the same fit test that guards height also rejects that size. Binary
* search then lands on the biggest size where every whole word fits the width and the whole
* title fits the height. No pixel-rounding fallback can sneak a mid-word break in, which was
* the "Promis / ed Land" bug.
*/
/**
* Hand the browser a brand-new title element whenever the text changes. On some GPUs (macOS
* Chrome on an external display) a large text element keeps a stale compositor backing store
* when its contents change, so the previous song stays painted under the new one — "AMAZING!"
* ghosting behind "Alleluia". A screenshot looks clean (the DOM is right); only the physical
* pixels are wrong, the signature of a layer that was never invalidated. A fresh node has no
* old backing store, so the ghost cannot survive the swap. Runs only when the title changes.
*/
let lastTitleText = null;
function setNowTitle(text) {
if (text === lastTitleText && nowTitleEl.isConnected) return;
lastTitleText = text;
const fresh = document.createElement('div');
fresh.className = 'now-title';
fresh.id = 'now-title';
fresh.textContent = text;
nowTitleEl.replaceWith(fresh);
nowTitleEl = fresh;
}
function fitNowTitle() {
// Status messages ("Nothing on screen") keep their CSS size. The resize observer calls
// straight into here, so the guard has to live here too.
if (nowTitleEl.classList.contains('is-waiting')) return;
const boxHeight = nowTitleBox.clientHeight;
const boxWidth = nowTitleBox.clientWidth;
const MIN = 14;
// Wrap only at spaces; never split a word.
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`;
// +1 absorbs sub-pixel rounding; because word-breaking is off, an over-wide word shows
// up here as horizontal overflow and is rejected rather than being split.
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; }
}
}
// Hysteresis: keep the current size unless the new best differs by more than a pixel.
// Sub-pixel box jitter (Windows fractional-DPI, a re-measured meta line) would otherwise
// flap the size ±1px every render — the "bigger and smaller text" the operator sees.
const current = parseFloat(nowTitleEl.style.fontSize) || 0;
// Only HOLD the current size if it still genuinely fits. A plain 1px band can straddle the
// line-wrap threshold (137px = one line fits, 138px = two lines overflow) and stick on the
// overflowing side — which is exactly how the title spilled out of the box.
if (current && Math.abs(current - best) <= 1 && fits(current)) {
nowTitleEl.style.fontSize = `${current}px`;
return;
}
nowTitleEl.style.fontSize = `${best}px`;
// Last resort: a single word so long it cannot fit even at MIN (unlikely for a song
// title). Rather than overflow the card, allow this one to break.
if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) {
nowTitleEl.style.overflowWrap = 'anywhere';
nowTitleEl.style.wordBreak = 'break-word';
}
}
/**
* Hide the NEXT rows that fall past the bottom of the list and report how many.
*
* Rows are hidden, never removed: NOW is a greedy flex item, so deleting rows would
* shrink the NEXT block, let NOW grow into the freed space, and ratchet the list down
* to nothing over successive renders. Keeping every row in the flow fixes the NEXT
* block's height, and `visibility: hidden` keeps a half-cut row off the screen.
*/
function markNextOverflow() {
const rows = Array.from(nextListEl.children);
for (const row of rows) row.style.visibility = '';
let hidden = 0;
// Revealing the "+ N more" line takes height away from the list, which can push one
// more row past the fold — so re-measure until it settles (bounded, only a few rows).
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 = 'Display offline';
return;
}
if (!status) {
liveTextEl.textContent = 'Connecting…';
return;
}
if (!status.enabled) {
liveTextEl.textContent = 'Not set up';
return;
}
if (state?.reachable) {
liveEl.classList.add('is-live');
liveTextEl.textContent = 'Live';
return;
}
liveEl.classList.add('is-down');
liveTextEl.textContent = 'Reconnecting…';
}
function renderNow() {
const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null;
const rawName = activeItem?.name || state?.activePresentationName || '';
// Clear anything a previous render appended below the progress bar.
for (const stale of screenEl.querySelectorAll('.now-error, .now-hint, .stale-note')) stale.remove();
if (feedOffline) {
// We are showing whatever was last on screen, and it is not moving. Say so.
setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Display offline');
nowTitleEl.classList.add('is-waiting');
nowTitleEl.style.fontSize = '';
nowTitleEl.style.whiteSpace = '';
nowMetaEl.textContent = '';
progressEl.hidden = true;
const staleEl = document.createElement('div');
staleEl.className = 'stale-note';
staleEl.textContent = status?.hostLabel
? `Not receiving updates — ${status.hostLabel} may be closed or asleep.`
: 'Not receiving updates from the display host.';
el('now').appendChild(staleEl);
return;
}
if (!rawName) {
// A status message is not a song title — leave it at its CSS size instead of
// running the fill-the-box fit, which would blow "No ProPresenter connection"
// up across the whole screen.
let hint = '';
if (!status?.enabled) {
setNowTitle('No ProPresenter connection');
// On a locked studio screen, "press the gear and type an IP" is advice nobody
// standing there can act on — the host supplies the right wording instead.
hint = status?.setupHint || 'Waiting for the display host to connect to ProPresenter.';
} else {
// "Waiting for ProPresenter" reads as a fault. When we are connected and ProPresenter
// simply has nothing live (it returns presentation_index: null with the output
// cleared), say that plainly so nobody goes looking 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 = '';
nowTitleEl.style.whiteSpace = '';
nowMetaEl.textContent = '';
progressEl.hidden = true;
if (hint) {
const hintEl = document.createElement('div');
hintEl.className = 'now-hint';
hintEl.textContent = hint;
el('now').appendChild(hintEl);
}
// Raw fetch/socket error text is for whoever administers the connection, not for a
// wall in a studio. It stays in the host app's own status line.
return;
}
setNowTitle(displayName(rawName));
nowTitleEl.classList.remove('is-waiting');
fitNowTitle();
// Re-fit once more after layout settles: on the first render after a navigation/resize the
// title box may not have its final height yet, and fitting against a too-tall box would let
// the title overflow for a frame. The ResizeObserver also covers this; this makes it instant.
window.requestAnimationFrame(() => {
if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle();
});
// An empty NEXT list looks broken unless the screen 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, so there is nothing listed after this.';
} else if (kind === 'unknown') {
why = 'ProPresenter did not return this playlist\u2019s items, so the next list is unavailable.';
} else if (Array.isArray(state?.items) && state.items.length) {
why = 'Last item in the playlist.';
}
if (why) {
const note = document.createElement('div');
note.className = 'now-hint';
note.textContent = why;
el('now').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();
// With nothing to list, NOW would otherwise grow into the entire screen and read as a
// broken page; the class caps it so the card still looks deliberate.
document.body.classList.toggle('no-upcoming', upcoming.length === 0);
for (const item of upcoming) {
const row = document.createElement('div');
const nameEl = document.createElement('div');
nameEl.className = 'next-name';
if (item.type === 'header') {
row.className = 'next-item is-header';
nameEl.textContent = displayName(item.name || '');
row.appendChild(nameEl);
nextListEl.appendChild(row);
continue;
}
row.className = '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 = '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 = 'type-tag';
tagEl.textContent = tag;
row.appendChild(tagEl);
}
}
nextListEl.appendChild(row);
}
nextLabelEl.hidden = upcoming.length === 0;
nextMoreEl.hidden = true;
// Measure synchronously so a half-visible row never reaches the screen; the extra frame
// is a safety net for the first render, when layout height isn't known yet.
markNextOverflow();
window.requestAnimationFrame(markNextOverflow);
}
function render() {
document.body.classList.toggle('is-offline', feedOffline);
const showTop = prefs.showPlaylist || feedOffline || !state?.reachable || !status?.enabled;
topRow.classList.toggle('hidden-row', !showTop);
playlistNameEl.textContent = prefs.showPlaylist ? state?.playlistName || '' : '';
renderLive();
renderNow();
renderNext();
}
/* ---------------- live feed ---------------- */
/**
* Reload the page when the host app has been updated to a new build.
*
* These screens are bookmarks nobody tends — a tab left open keeps running the JavaScript it
* first loaded, so a fixed display page would never reach the wall until someone walked over
* and refreshed it. The host stamps its version into every status frame; when it changes from
* what this tab loaded with, the host was reinstalled, so pick up its new page automatically.
*/
function checkForHostUpdate() {
const version = status && status.hostVersion;
if (!version) return;
if (loadedHostVersion === null) {
loadedHostVersion = version;
return;
}
if (version !== loadedHostVersion) {
// location.reload() refetches the page and its assets (served no-store), so the tab
// comes back running the new build. loadedHostVersion resets on reload, so no loop.
window.location.reload();
}
}
function connectStream() {
if (stream) {
try { stream.close(); } catch { /* already closed */ }
stream = null;
}
// Relative on purpose so the page works from any host, port, or mount path.
const source = new EventSource('api/stream');
stream = source;
source.addEventListener('open', () => {
feedOffline = false;
render();
});
source.addEventListener('state', (event) => {
let incoming;
try {
incoming = JSON.parse(event.data);
} catch {
return;
}
state = incoming;
feedOffline = false;
render();
});
source.addEventListener('status', (event) => {
let incoming;
try {
incoming = JSON.parse(event.data);
} catch {
return;
}
status = incoming;
feedOffline = false;
checkForHostUpdate();
syncPanelFromStatus();
render();
});
source.addEventListener('error', () => {
// EventSource retries by itself, but the screen must admit it is frozen meanwhile —
// otherwise a stale lineup is indistinguishable from a live one.
feedOffline = true;
render();
});
}
/**
* Force a fresh connection. iPadOS suspends timers and sockets on a sleeping tab and does
* not always resume an EventSource, which would leave a wall display frozen indefinitely.
*/
function reconnectStream() {
// OPEN (1) or still CONNECTING (0) both mean a connection is already in flight. Opening
// another would leave this page holding two streams, which the host then counts twice.
if (stream && (stream.readyState === 0 || stream.readyState === 1)) return;
connectStream();
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') reconnectStream();
});
window.addEventListener('online', reconnectStream);
window.addEventListener('pageshow', reconnectStream);
/* ---------------- settings panel ---------------- */
function setPanelStatus(message, isError = false) {
panelStatus.textContent = message;
panelStatus.classList.toggle('is-error', Boolean(isError));
}
function syncPanelFromStatus() {
if (!status) return;
if (document.activeElement !== hostInput) hostInput.value = status.host || '';
if (document.activeElement !== portInput) {
portInput.value = status.port ? String(status.port) : String(status.defaultProPresenterPort || '');
}
passwordInput.placeholder = status.hasPassword ? 'Saved — leave blank to keep' : 'Leave blank if none';
const locked = Boolean(status.readOnly);
for (const control of [hostInput, portInput, passwordInput, btnConnect, btnDisconnect]) {
control.disabled = locked;
}
// A row of dead grey fields invites people to keep poking at them. Replace the whole
// section with a sentence saying where the connection actually lives.
if (connectionSection) connectionSection.hidden = locked;
if (managedSection) managedSection.hidden = !locked;
if (managedNote) {
managedNote.textContent = status.enabled
? `This screen follows ${status.hostLabel || 'the display host'}, which is connected to ProPresenter. Nothing to set up here.`
: `This screen follows ${status.hostLabel || 'the display host'}. ${status.setupHint || ''}`.trim();
}
if (aboutLine) {
const version = status.hostVersion ? `v${status.hostVersion}` : '';
aboutLine.textContent = [
'Stage Now / Next',
version,
status.hostLabel ? `served by ${status.hostLabel}` : '',
].filter(Boolean).join(' · ');
}
urlList.innerHTML = '';
const addresses = Array.isArray(status.addresses) && status.addresses.length
? status.addresses
: (Array.isArray(status.viewerUrls) ? status.viewerUrls : []).map((url) => ({ url, detail: '' }));
if (!addresses.length) {
const none = document.createElement('span');
none.className = 'muted';
none.textContent = 'No network address detected on this machine.';
urlList.appendChild(none);
}
for (const entry of addresses) {
const row = document.createElement('div');
row.textContent = entry.url;
urlList.appendChild(row);
if (entry.detail) {
const note = document.createElement('span');
note.className = 'muted';
note.textContent = entry.detail;
urlList.appendChild(note);
}
}
if (locked) {
setPanelStatus('');
} else if (status.enabled) {
setPanelStatus(
status.reachable
? `Following ProPresenter at ${status.host}:${status.port}.`
: `Saved ${status.host}:${status.port} — trying to reach ProPresenter…`,
);
} else if (!lastErrorShown) {
setPanelStatus('Not connected yet. Enter the ProPresenter IP and port.');
}
}
function openPanel() {
panel.classList.remove('hidden');
panelBackdrop.classList.remove('hidden');
syncPanelFromStatus();
hostInput.focus();
}
function closePanel() {
panel.classList.add('hidden');
panelBackdrop.classList.add('hidden');
passwordInput.value = '';
lastErrorShown = '';
}
async function postJson(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Request failed (${response.status}).`);
return data;
}
async function connectProPresenter() {
const host = hostInput.value.trim();
const port = portInput.value.trim();
if (!host || !port) {
lastErrorShown = 'missing';
setPanelStatus('Enter the ProPresenter host and port.', true);
return;
}
btnConnect.disabled = true;
setPanelStatus('Connecting to ProPresenter…');
try {
// An untouched password field means "keep whatever is saved".
const payload = { host, port };
if (passwordInput.value !== '') payload.password = passwordInput.value;
const data = await postJson('api/connect', payload);
status = data.status;
lastErrorShown = '';
passwordInput.value = '';
syncPanelFromStatus();
setPanelStatus(`Connected. Every screen on this server is now following ${host}:${port}.`);
} catch (error) {
lastErrorShown = 'connect';
setPanelStatus(error instanceof Error ? error.message : 'Could not connect to ProPresenter.', true);
} finally {
btnConnect.disabled = false;
}
}
async function disconnectProPresenter() {
btnDisconnect.disabled = true;
try {
const data = await postJson('api/disconnect');
status = data.status;
state = null;
lastErrorShown = '';
syncPanelFromStatus();
setPanelStatus('Disconnected. Host and port are remembered for next time.');
render();
} catch (error) {
setPanelStatus(error instanceof Error ? error.message : 'Could not disconnect.', true);
} finally {
btnDisconnect.disabled = false;
}
}
/* ---------------- chrome auto-hide (the display stays clean) ---------------- */
function revealChrome() {
document.body.classList.add('chrome-visible');
if (chromeTimer) window.clearTimeout(chromeTimer);
chromeTimer = window.setTimeout(() => {
if (panel.classList.contains('hidden')) document.body.classList.remove('chrome-visible');
}, CHROME_IDLE_MS);
}
/* ---------------- wiring ---------------- */
gearBtn.addEventListener('click', openPanel);
panelClose.addEventListener('click', closePanel);
panelBackdrop.addEventListener('click', closePanel);
btnConnect.addEventListener('click', () => void connectProPresenter());
btnDisconnect.addEventListener('click', () => void disconnectProPresenter());
btnFullscreen.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else {
document.exitFullscreen?.().catch(() => {});
}
});
for (const input of [hostInput, portInput, passwordInput]) {
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void connectProPresenter();
}
if (event.key === 'Escape') {
event.preventDefault();
closePanel();
}
});
}
scaleRow.addEventListener('click', (event) => {
const chip = event.target.closest('.chip');
if (!chip) return;
prefs.scale = Number(chip.dataset.scale) || 1;
savePrefs();
applyPrefs();
});
optPlaylist.addEventListener('change', () => {
prefs.showPlaylist = optPlaylist.checked;
savePrefs();
render();
});
optProgress.addEventListener('change', () => {
prefs.showProgress = optProgress.checked;
savePrefs();
render();
});
optClean.addEventListener('change', () => {
prefs.cleanTitles = optClean.checked;
savePrefs();
render();
});
for (const event of ['pointermove', 'pointerdown', 'keydown', 'touchstart']) {
window.addEventListener(event, revealChrome, { passive: true });
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !panel.classList.contains('hidden')) closePanel();
if (event.key === 'f' && !event.metaKey && !event.ctrlKey && panel.classList.contains('hidden')) {
btnFullscreen.click();
}
});
// Re-fit on any size change: window resize, fullscreen, rotation, or scale change.
if (typeof ResizeObserver !== 'undefined') {
// Re-fit only when a watched box ACTUALLY changed size (window resize, rotation), never
// just because the fitter or the overflow-marker wrote to the DOM. Without this guard the
// observer reacts to its own effects and the title size oscillates every render.
let lastTitleW = 0;
let lastTitleH = 0;
let lastListH = 0;
let pending = false;
const observer = new ResizeObserver(() => {
const tw = Math.round(nowTitleBox.clientWidth);
const th = Math.round(nowTitleBox.clientHeight);
const lh = Math.round(nextListEl.clientHeight);
if (tw === lastTitleW && th === lastTitleH && lh === lastListH) return;
lastTitleW = tw; lastTitleH = th; lastListH = lh;
if (pending) return;
pending = true;
window.requestAnimationFrame(() => {
pending = false;
fitNowTitle();
// markNextOverflow re-evaluates row visibility for the new size WITHOUT rebuilding the
// list (a rebuild would flash and could re-enter this observer).
markNextOverflow();
});
});
observer.observe(nowTitleBox);
observer.observe(nextListEl);
}
/** Best-effort: keep a dedicated display awake. Only granted in a secure context. */
async function keepAwake() {
try {
if (!('wakeLock' in navigator)) return;
let lock = await navigator.wakeLock.request('screen');
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && lock?.released !== false) {
try {
lock = await navigator.wakeLock.request('screen');
} catch {
/* denied — nothing to do */
}
}
});
} catch {
/* not available over plain http, or refused — the page still works */
}
}
loadPrefs();
applyPrefs();
revealChrome();
connectStream();
void keepAwake();
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f12" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Stage Now / Next</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="screen" id="screen">
<header class="top" id="top">
<div class="playlist-name" id="playlist-name"></div>
<div class="live" id="live">
<span class="live-dot" id="live-dot" aria-hidden="true"></span>
<span class="live-text" id="live-text">Connecting…</span>
</div>
</header>
<section class="now" id="now" aria-live="polite">
<div class="now-label">Now</div>
<div class="now-title-box" id="now-title-box">
<div class="now-title" id="now-title">Waiting for ProPresenter…</div>
</div>
<div class="now-meta" id="now-meta"></div>
<div class="progress" id="progress" hidden>
<div class="progress-fill" id="progress-fill"></div>
</div>
</section>
<section class="next-wrap">
<div class="section-label" id="next-label" hidden>Next</div>
<div class="next-list" id="next-list"></div>
<div class="next-more" id="next-more" hidden></div>
</section>
</div>
<button type="button" class="gear" id="gear" aria-label="Display settings" title="Display settings">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"
fill="currentColor"
/>
</svg>
</button>
<div class="panel-backdrop hidden" id="panel-backdrop"></div>
<aside class="panel hidden" id="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title">
<div class="panel-head">
<h1 class="panel-title" id="panel-title">Display settings</h1>
<button type="button" class="btn ghost" id="panel-close" aria-label="Close settings">Done</button>
</div>
<div class="panel-section" id="managed-section" hidden>
<h2 class="panel-section-title">ProPresenter connection</h2>
<p class="panel-copy" id="managed-note"></p>
</div>
<div class="panel-section" id="connection-section">
<h2 class="panel-section-title">ProPresenter connection</h2>
<p class="panel-copy">
IP address and network port of the computer running ProPresenter. Turn the API on in
ProPresenter ▸ Preferences ▸ Network.
</p>
<label class="field">
<span>Host / IP address</span>
<input type="text" id="host" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="192.168.1.50" />
</label>
<label class="field field-narrow">
<span>Port</span>
<input type="text" id="port" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="1025" />
</label>
<label class="field">
<span>Password <em>(only if set in ProPresenter)</em></span>
<input type="password" id="password" autocomplete="off" spellcheck="false" placeholder="Leave blank to keep current" />
</label>
<div class="panel-actions">
<button type="button" class="btn primary" id="btn-connect">Connect</button>
<button type="button" class="btn" id="btn-disconnect">Disconnect</button>
</div>
<div class="panel-status" id="panel-status"></div>
</div>
<div class="panel-section">
<h2 class="panel-section-title">This screen</h2>
<p class="panel-copy">Saved on this device only, so every screen can be sized for its own room.</p>
<div class="chip-row" id="scale-row" role="group" aria-label="Text size">
<button type="button" class="chip" data-scale="0.8">Small</button>
<button type="button" class="chip" data-scale="1">Medium</button>
<button type="button" class="chip" data-scale="1.25">Large</button>
<button type="button" class="chip" data-scale="1.5">Huge</button>
</div>
<label class="toggle">
<input type="checkbox" id="opt-playlist" checked />
<span>Show the playlist name</span>
</label>
<label class="toggle">
<input type="checkbox" id="opt-progress" checked />
<span>Show the slide progress bar</span>
</label>
<label class="toggle">
<input type="checkbox" id="opt-clean" checked />
<span>Tidy titles (hide <code>[ Full ]</code> tags and date codes)</span>
</label>
<div class="panel-actions">
<button type="button" class="btn" id="btn-fullscreen">Fullscreen</button>
</div>
</div>
<div class="panel-section">
<h2 class="panel-section-title">Other screens</h2>
<p class="panel-copy">Open any of these on a studio machine, hallway TV, or tablet — they all follow this same connection.</p>
<div class="url-list" id="url-list"></div>
</div>
<div class="panel-section">
<h2 class="panel-section-title">About</h2>
<p class="panel-copy" id="about-line">Stage Now / Next</p>
</div>
</aside>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,638 @@
/* Stage Now / Next — standalone ProPresenter display.
Visual language matches the Band Lineup app's Now/Next column, scaled up from a
sidebar to a whole screen. Sizes are viewport-relative and multiplied by --scale
(set per device in the settings panel) so one page suits a 15" studio monitor,
a 65" hallway TV, and an iPad. */
:root {
--scale: 1;
--bg: #0f0f12;
--panel: #16161a;
--line: #2a2a32;
--line-soft: #2f2f3a;
--text: #f4f6fb;
--text-dim: #9ea7bc;
--text-faint: #7a7a8a;
--accent: #7fa8e0;
--accent-deep: #4a6fa5;
--danger: #d98c8c;
--unit: clamp(11px, 1.35vh, 22px);
--pad: calc(var(--unit) * 1.4);
--gap: calc(var(--unit) * 0.9);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
/* dvh so iPad Safari's collapsing toolbars don't leave the layout taller than the glass;
the 100% first line keeps older browsers working. */
height: 100%;
height: 100dvh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
/* Stop iOS inflating text on a rotated wall display, and stop a stray pinch or
double-tap from zooming a screen nobody is standing at. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
touch-action: pan-x pan-y;
user-select: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
}
/* ---------- the display ---------- */
.screen {
height: 100%;
display: flex;
flex-direction: column;
gap: var(--gap);
padding: calc(var(--pad) + env(safe-area-inset-top)) calc(var(--pad) + env(safe-area-inset-right))
calc(var(--pad) + env(safe-area-inset-bottom)) calc(var(--pad) + env(safe-area-inset-left));
}
.top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--gap);
flex-shrink: 0;
min-height: 0;
}
.top.hidden-row {
display: none;
}
.playlist-name {
flex: 1;
min-width: 0;
font-size: calc(var(--unit) * 1.05 * var(--scale));
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a8a99;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.live {
display: flex;
align-items: center;
gap: calc(var(--unit) * 0.5);
flex-shrink: 0;
font-size: calc(var(--unit) * 0.95 * var(--scale));
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.live-dot {
width: calc(var(--unit) * 0.62);
height: calc(var(--unit) * 0.62);
border-radius: 999px;
background: #55555f;
transition: background 0.3s ease, box-shadow 0.3s ease;
}
.live.is-live .live-dot {
background: #6fd08c;
box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(111, 208, 140, 0.18);
}
.live.is-down .live-dot {
background: var(--danger);
box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(217, 140, 140, 0.16);
}
/* Idle screens don't need a status chip staring at them — it fades once we're live. */
.live.is-live .live-text {
opacity: 0;
transition: opacity 0.6s ease 1.4s;
}
/* Losing the feed is the one thing a wall display MUST admit to, so this overrides the
"hide the top row" preference — otherwise a frozen lineup looks like a live one. */
body.is-offline .top {
display: flex !important;
}
body.is-offline .live-text {
opacity: 1 !important;
color: var(--danger);
}
body.is-offline .now {
border-color: rgba(217, 140, 140, 0.5);
}
.stale-note {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.4);
font-size: calc(var(--unit) * 1 * var(--scale));
font-weight: 600;
color: var(--danger);
}
/* With no NEXT list there is nothing below the card, so centre it rather than leaving a
tall band of empty black under a top-aligned card. */
body.no-upcoming .screen {
justify-content: center;
}
/* FIXED height. When NOW was flexible it shared vertical space with the NEXT block and the
two fed back every frame — the fitter sized the title to the leftover space, which changed
how many NEXT rows fit, which changed the leftover space… a two-layout strobe. A fixed NOW
makes NEXT depend on NOW and never the reverse, so the title box is deterministic and the
layout cannot oscillate. */
.now {
flex: 0 0 auto;
height: 40vh;
min-height: 180px;
display: flex;
flex-direction: column;
border: 1px solid #30303c;
border-radius: calc(var(--unit) * 1.1);
background: linear-gradient(160deg, rgba(61, 90, 128, 0.22), rgba(26, 26, 34, 0.6));
padding: calc(var(--pad) * 1.05) calc(var(--pad) * 1.2);
}
.now-label {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.95 * var(--scale));
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.now-title-box {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
overflow: hidden;
margin-top: calc(var(--unit) * 0.4);
}
.now-title {
width: 100%;
/* Font size is set by fitNowTitle() in app.js so the title fills the box. Keep the
line-height at or above the font's natural ~1.2: a tighter line box clips the ink of
ascenders and descenders (the "y" in "This Is The Day") once the size gets large. */
font-size: calc(var(--unit) * 3 * var(--scale));
font-weight: 700;
line-height: 1.2;
color: var(--text);
/* Wrapping is controlled by fitNowTitle() in app.js, which forbids mid-word breaks and
sizes down until whole words fit. Defaulting to break-word here would let the browser
split a word ("Promis / ed Land") between fitter runs. */
overflow-wrap: normal;
word-break: normal;
}
/* Status text, not a title: a readable size, never the fill-the-box treatment. */
.now-title.is-waiting {
color: var(--text-faint);
font-weight: 600;
font-size: calc(var(--unit) * 1.9 * var(--scale));
white-space: normal;
}
.now-hint {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.5);
font-size: calc(var(--unit) * 1.05 * var(--scale));
line-height: 1.45;
color: var(--text-dim);
}
.now-meta {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.55);
font-size: calc(var(--unit) * 1.35 * var(--scale));
font-weight: 600;
color: #c6cada;
font-variant-numeric: tabular-nums;
/* Never wrap: a wrap to 2 lines changes the title box height, which refits the title —
the source of the every-other-slide size flap as the slide-number width changes. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.now-meta:empty {
display: none;
}
.now-error {
margin-top: calc(var(--unit) * 0.4);
font-size: calc(var(--unit) * 0.9 * var(--scale));
color: var(--danger);
word-break: break-word;
}
.progress {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.7);
height: calc(var(--unit) * 0.45);
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999px;
background: var(--accent-deep);
transition: width 0.25s ease;
}
.next-wrap {
/* Takes whatever is left below the fixed NOW card; markNextOverflow() hides rows past the
fold into a "+ N more" line. */
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--unit) * 0.6);
}
.section-label {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.95 * var(--scale));
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-faint);
}
.next-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--unit) * 0.55);
overflow: hidden;
}
.next-item {
display: flex;
align-items: center;
gap: var(--gap);
padding: calc(var(--unit) * 0.75) calc(var(--unit) * 1);
border: 1px solid var(--line);
border-radius: calc(var(--unit) * 0.75);
background: rgba(26, 26, 32, 0.7);
}
.next-name {
flex: 1;
min-width: 0;
font-size: calc(var(--unit) * 1.45 * var(--scale));
font-weight: 600;
color: #e2e4ec;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count-chip {
flex-shrink: 0;
min-width: calc(var(--unit) * 2.2);
padding: calc(var(--unit) * 0.2) calc(var(--unit) * 0.6);
text-align: center;
border-radius: 999px;
background: rgba(74, 111, 165, 0.28);
color: #cfe0f7;
font-size: calc(var(--unit) * 1.05 * var(--scale));
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.type-tag {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.85 * var(--scale));
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a8a99;
}
.next-item.is-header {
background: none;
border: none;
border-bottom: 1px solid var(--line-soft);
border-radius: 0;
padding: calc(var(--unit) * 0.7) calc(var(--unit) * 0.15) calc(var(--unit) * 0.25);
}
.next-item.is-header .next-name {
font-size: calc(var(--unit) * 0.95 * var(--scale));
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8a8a99;
}
.next-more {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.95 * var(--scale));
color: var(--text-faint);
font-weight: 600;
}
.empty-next {
font-size: calc(var(--unit) * 1.1 * var(--scale));
color: var(--text-faint);
}
/* ---------- settings (hidden until someone reaches for it) ---------- */
.gear {
position: fixed;
right: calc(var(--pad) + env(safe-area-inset-right));
bottom: calc(var(--pad) + env(safe-area-inset-bottom));
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
/* A floating control: opaque with a shadow so it reads as sitting above the NEXT list
during the few seconds it is visible after someone touches the screen. */
background: #1c1c22;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
color: #9a9aa8;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, color 0.2s ease, border-color 0.2s ease;
}
body.chrome-visible .gear {
opacity: 1;
pointer-events: auto;
}
.gear:hover {
color: var(--text);
border-color: #3d3d4a;
}
.gear svg {
width: 22px;
height: 22px;
}
.panel-backdrop {
position: fixed;
inset: 0;
background: rgba(6, 6, 8, 0.62);
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(440px, 100vw);
display: flex;
flex-direction: column;
gap: 18px;
padding: calc(18px + env(safe-area-inset-top)) 20px calc(24px + env(safe-area-inset-bottom));
background: var(--panel);
border-left: 1px solid var(--line);
overflow-y: auto;
user-select: text;
font-size: 14px;
}
.panel.hidden,
.panel-backdrop.hidden {
display: none;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.panel-section {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.panel-section-title {
margin: 0;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #8a8a99;
}
.panel-copy {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-dim);
}
.panel-copy code {
font-size: 12px;
color: #cfe0f7;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #a0a0ae;
}
.field em {
font-style: normal;
color: var(--text-faint);
}
.field input {
padding: 10px 12px;
font-size: 15px;
color: var(--text);
background: #0f0f13;
border: 1px solid #33333d;
border-radius: 9px;
}
.field input:focus {
outline: none;
border-color: var(--accent-deep);
}
.field-narrow input {
max-width: 140px;
}
.panel-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text);
background: #24242c;
border: 1px solid #35353f;
border-radius: 9px;
cursor: pointer;
}
.btn:hover {
background: #2b2b34;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.btn.primary {
background: var(--accent-deep);
border-color: #5b83bd;
}
.btn.primary:hover {
background: #547cb6;
}
.btn.ghost {
background: none;
border-color: transparent;
color: var(--text-dim);
}
.panel-status {
min-height: 18px;
font-size: 13px;
line-height: 1.4;
color: var(--text-dim);
}
.panel-status.is-error {
color: #ffb1b1;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 8px 13px;
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
background: #1e1e25;
border: 1px solid #33333d;
border-radius: 999px;
cursor: pointer;
}
.chip.is-on {
color: #cfe0f7;
background: rgba(74, 111, 165, 0.3);
border-color: #4a6fa5;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #c6cada;
cursor: pointer;
}
.toggle input {
width: 17px;
height: 17px;
accent-color: var(--accent-deep);
}
.url-list {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #cfe0f7;
word-break: break-all;
}
.url-list span.muted {
font-weight: 500;
color: var(--text-faint);
}
/* Short windows (a strip display above a door, or a small browser window): shed the NOW
card's chrome rather than squeezing the title down to nothing. */
@media (max-height: 460px) {
.now {
height: 62vh;
min-height: 0;
padding: calc(var(--pad) * 0.55) calc(var(--pad) * 0.9);
}
.now-label {
font-size: calc(var(--unit) * 0.8 * var(--scale));
}
.now-meta {
margin-top: calc(var(--unit) * 0.25);
font-size: calc(var(--unit) * 1 * var(--scale));
}
/* The meta line already reads "Slide 12 / 43", so the bar is the first thing to go. */
.progress {
display: none;
}
}
/* Phones and small tablets in portrait: give NOW less height so NEXT still shows. */
@media (max-aspect-ratio: 3 / 4) {
.now {
height: 38vh;
}
}
@media (max-width: 520px) {
.panel {
width: 100vw;
border-left: none;
}
}