Band Lineup Display, the backstage app itself

The Electron app that runs on the backstage PC: stage lineup photos with
mic/pack labels, packaged for Windows. It also carries the ProPresenter
Now/Next feature in two forms that share one engine — propresenter-core, where
every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone
display it serves.

Until now this existed only on the production machine and as a read-only
snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build
output, and credentials, photos and processed images are written to the
Electron userData directory, never here. tmp/pp-mock.js stays — it is the
ProPresenter mock ProdDash's own CLAUDE.md tells people to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit 96ac2dec2d
28 changed files with 17694 additions and 0 deletions
+830
View File
@@ -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();