Files
ProdDash/band-lineup-display/propresenter-app/public/app.js

831 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();