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:
@@ -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();
|
||||
Reference in New Issue
Block a user