831 lines
29 KiB
JavaScript
831 lines
29 KiB
JavaScript
'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();
|