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();
|
||||
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f0f12" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>Stage Now / Next</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="screen" id="screen">
|
||||
<header class="top" id="top">
|
||||
<div class="playlist-name" id="playlist-name"></div>
|
||||
<div class="live" id="live">
|
||||
<span class="live-dot" id="live-dot" aria-hidden="true"></span>
|
||||
<span class="live-text" id="live-text">Connecting…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="now" id="now" aria-live="polite">
|
||||
<div class="now-label">Now</div>
|
||||
<div class="now-title-box" id="now-title-box">
|
||||
<div class="now-title" id="now-title">Waiting for ProPresenter…</div>
|
||||
</div>
|
||||
<div class="now-meta" id="now-meta"></div>
|
||||
<div class="progress" id="progress" hidden>
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="next-wrap">
|
||||
<div class="section-label" id="next-label" hidden>Next</div>
|
||||
<div class="next-list" id="next-list"></div>
|
||||
<div class="next-more" id="next-more" hidden></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button type="button" class="gear" id="gear" aria-label="Display settings" title="Display settings">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="panel-backdrop hidden" id="panel-backdrop"></div>
|
||||
<aside class="panel hidden" id="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title">
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title" id="panel-title">Display settings</h1>
|
||||
<button type="button" class="btn ghost" id="panel-close" aria-label="Close settings">Done</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" id="managed-section" hidden>
|
||||
<h2 class="panel-section-title">ProPresenter connection</h2>
|
||||
<p class="panel-copy" id="managed-note"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" id="connection-section">
|
||||
<h2 class="panel-section-title">ProPresenter connection</h2>
|
||||
<p class="panel-copy">
|
||||
IP address and network port of the computer running ProPresenter. Turn the API on in
|
||||
ProPresenter ▸ Preferences ▸ Network.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Host / IP address</span>
|
||||
<input type="text" id="host" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="192.168.1.50" />
|
||||
</label>
|
||||
<label class="field field-narrow">
|
||||
<span>Port</span>
|
||||
<input type="text" id="port" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="1025" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password <em>(only if set in ProPresenter)</em></span>
|
||||
<input type="password" id="password" autocomplete="off" spellcheck="false" placeholder="Leave blank to keep current" />
|
||||
</label>
|
||||
<div class="panel-actions">
|
||||
<button type="button" class="btn primary" id="btn-connect">Connect</button>
|
||||
<button type="button" class="btn" id="btn-disconnect">Disconnect</button>
|
||||
</div>
|
||||
<div class="panel-status" id="panel-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-section-title">This screen</h2>
|
||||
<p class="panel-copy">Saved on this device only, so every screen can be sized for its own room.</p>
|
||||
<div class="chip-row" id="scale-row" role="group" aria-label="Text size">
|
||||
<button type="button" class="chip" data-scale="0.8">Small</button>
|
||||
<button type="button" class="chip" data-scale="1">Medium</button>
|
||||
<button type="button" class="chip" data-scale="1.25">Large</button>
|
||||
<button type="button" class="chip" data-scale="1.5">Huge</button>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="opt-playlist" checked />
|
||||
<span>Show the playlist name</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="opt-progress" checked />
|
||||
<span>Show the slide progress bar</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="opt-clean" checked />
|
||||
<span>Tidy titles (hide <code>[ Full ]</code> tags and date codes)</span>
|
||||
</label>
|
||||
<div class="panel-actions">
|
||||
<button type="button" class="btn" id="btn-fullscreen">Fullscreen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-section-title">Other screens</h2>
|
||||
<p class="panel-copy">Open any of these on a studio machine, hallway TV, or tablet — they all follow this same connection.</p>
|
||||
<div class="url-list" id="url-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-section-title">About</h2>
|
||||
<p class="panel-copy" id="about-line">Stage Now / Next</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,638 @@
|
||||
/* Stage Now / Next — standalone ProPresenter display.
|
||||
Visual language matches the Band Lineup app's Now/Next column, scaled up from a
|
||||
sidebar to a whole screen. Sizes are viewport-relative and multiplied by --scale
|
||||
(set per device in the settings panel) so one page suits a 15" studio monitor,
|
||||
a 65" hallway TV, and an iPad. */
|
||||
|
||||
:root {
|
||||
--scale: 1;
|
||||
--bg: #0f0f12;
|
||||
--panel: #16161a;
|
||||
--line: #2a2a32;
|
||||
--line-soft: #2f2f3a;
|
||||
--text: #f4f6fb;
|
||||
--text-dim: #9ea7bc;
|
||||
--text-faint: #7a7a8a;
|
||||
--accent: #7fa8e0;
|
||||
--accent-deep: #4a6fa5;
|
||||
--danger: #d98c8c;
|
||||
--unit: clamp(11px, 1.35vh, 22px);
|
||||
--pad: calc(var(--unit) * 1.4);
|
||||
--gap: calc(var(--unit) * 0.9);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
/* dvh so iPad Safari's collapsing toolbars don't leave the layout taller than the glass;
|
||||
the 100% first line keeps older browsers working. */
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Stop iOS inflating text on a rotated wall display, and stop a stray pinch or
|
||||
double-tap from zooming a screen nobody is standing at. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
touch-action: pan-x pan-y;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- the display ---------- */
|
||||
|
||||
.screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
padding: calc(var(--pad) + env(safe-area-inset-top)) calc(var(--pad) + env(safe-area-inset-right))
|
||||
calc(var(--pad) + env(safe-area-inset-bottom)) calc(var(--pad) + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap);
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.top.hidden-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: calc(var(--unit) * 1.05 * var(--scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8a8a99;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--unit) * 0.5);
|
||||
flex-shrink: 0;
|
||||
font-size: calc(var(--unit) * 0.95 * var(--scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: calc(var(--unit) * 0.62);
|
||||
height: calc(var(--unit) * 0.62);
|
||||
border-radius: 999px;
|
||||
background: #55555f;
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.live.is-live .live-dot {
|
||||
background: #6fd08c;
|
||||
box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(111, 208, 140, 0.18);
|
||||
}
|
||||
|
||||
.live.is-down .live-dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(217, 140, 140, 0.16);
|
||||
}
|
||||
|
||||
/* Idle screens don't need a status chip staring at them — it fades once we're live. */
|
||||
.live.is-live .live-text {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease 1.4s;
|
||||
}
|
||||
|
||||
/* Losing the feed is the one thing a wall display MUST admit to, so this overrides the
|
||||
"hide the top row" preference — otherwise a frozen lineup looks like a live one. */
|
||||
body.is-offline .top {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
body.is-offline .live-text {
|
||||
opacity: 1 !important;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
body.is-offline .now {
|
||||
border-color: rgba(217, 140, 140, 0.5);
|
||||
}
|
||||
|
||||
.stale-note {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--unit) * 0.4);
|
||||
font-size: calc(var(--unit) * 1 * var(--scale));
|
||||
font-weight: 600;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* With no NEXT list there is nothing below the card, so centre it rather than leaving a
|
||||
tall band of empty black under a top-aligned card. */
|
||||
body.no-upcoming .screen {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* FIXED height. When NOW was flexible it shared vertical space with the NEXT block and the
|
||||
two fed back every frame — the fitter sized the title to the leftover space, which changed
|
||||
how many NEXT rows fit, which changed the leftover space… a two-layout strobe. A fixed NOW
|
||||
makes NEXT depend on NOW and never the reverse, so the title box is deterministic and the
|
||||
layout cannot oscillate. */
|
||||
.now {
|
||||
flex: 0 0 auto;
|
||||
height: 40vh;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #30303c;
|
||||
border-radius: calc(var(--unit) * 1.1);
|
||||
background: linear-gradient(160deg, rgba(61, 90, 128, 0.22), rgba(26, 26, 34, 0.6));
|
||||
padding: calc(var(--pad) * 1.05) calc(var(--pad) * 1.2);
|
||||
}
|
||||
|
||||
.now-label {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(var(--unit) * 0.95 * var(--scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.now-title-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
margin-top: calc(var(--unit) * 0.4);
|
||||
}
|
||||
|
||||
.now-title {
|
||||
width: 100%;
|
||||
/* Font size is set by fitNowTitle() in app.js so the title fills the box. Keep the
|
||||
line-height at or above the font's natural ~1.2: a tighter line box clips the ink of
|
||||
ascenders and descenders (the "y" in "This Is The Day") once the size gets large. */
|
||||
font-size: calc(var(--unit) * 3 * var(--scale));
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
/* Wrapping is controlled by fitNowTitle() in app.js, which forbids mid-word breaks and
|
||||
sizes down until whole words fit. Defaulting to break-word here would let the browser
|
||||
split a word ("Promis / ed Land") between fitter runs. */
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Status text, not a title: a readable size, never the fill-the-box treatment. */
|
||||
.now-title.is-waiting {
|
||||
color: var(--text-faint);
|
||||
font-weight: 600;
|
||||
font-size: calc(var(--unit) * 1.9 * var(--scale));
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.now-hint {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--unit) * 0.5);
|
||||
font-size: calc(var(--unit) * 1.05 * var(--scale));
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.now-meta {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--unit) * 0.55);
|
||||
font-size: calc(var(--unit) * 1.35 * var(--scale));
|
||||
font-weight: 600;
|
||||
color: #c6cada;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Never wrap: a wrap to 2 lines changes the title box height, which refits the title —
|
||||
the source of the every-other-slide size flap as the slide-number width changes. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.now-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.now-error {
|
||||
margin-top: calc(var(--unit) * 0.4);
|
||||
font-size: calc(var(--unit) * 0.9 * var(--scale));
|
||||
color: var(--danger);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.progress {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--unit) * 0.7);
|
||||
height: calc(var(--unit) * 0.45);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-deep);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.next-wrap {
|
||||
/* Takes whatever is left below the fixed NOW card; markNextOverflow() hides rows past the
|
||||
fold into a "+ N more" line. */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--unit) * 0.6);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(var(--unit) * 0.95 * var(--scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.next-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--unit) * 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: calc(var(--unit) * 0.75) calc(var(--unit) * 1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--unit) * 0.75);
|
||||
background: rgba(26, 26, 32, 0.7);
|
||||
}
|
||||
|
||||
.next-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: calc(var(--unit) * 1.45 * var(--scale));
|
||||
font-weight: 600;
|
||||
color: #e2e4ec;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
flex-shrink: 0;
|
||||
min-width: calc(var(--unit) * 2.2);
|
||||
padding: calc(var(--unit) * 0.2) calc(var(--unit) * 0.6);
|
||||
text-align: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(74, 111, 165, 0.28);
|
||||
color: #cfe0f7;
|
||||
font-size: calc(var(--unit) * 1.05 * var(--scale));
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(var(--unit) * 0.85 * var(--scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8a8a99;
|
||||
}
|
||||
|
||||
.next-item.is-header {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
border-radius: 0;
|
||||
padding: calc(var(--unit) * 0.7) calc(var(--unit) * 0.15) calc(var(--unit) * 0.25);
|
||||
}
|
||||
|
||||
.next-item.is-header .next-name {
|
||||
font-size: calc(var(--unit) * 0.95 * var(--scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a8a99;
|
||||
}
|
||||
|
||||
.next-more {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(var(--unit) * 0.95 * var(--scale));
|
||||
color: var(--text-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-next {
|
||||
font-size: calc(var(--unit) * 1.1 * var(--scale));
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---------- settings (hidden until someone reaches for it) ---------- */
|
||||
|
||||
.gear {
|
||||
position: fixed;
|
||||
right: calc(var(--pad) + env(safe-area-inset-right));
|
||||
bottom: calc(var(--pad) + env(safe-area-inset-bottom));
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
/* A floating control: opaque with a shadow so it reads as sitting above the NEXT list
|
||||
during the few seconds it is visible after someone touches the screen. */
|
||||
background: #1c1c22;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||
color: #9a9aa8;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
body.chrome-visible .gear {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gear:hover {
|
||||
color: var(--text);
|
||||
border-color: #3d3d4a;
|
||||
}
|
||||
|
||||
.gear svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 8, 0.62);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(440px, 100vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: calc(18px + env(safe-area-inset-top)) 20px calc(24px + env(safe-area-inset-bottom));
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel.hidden,
|
||||
.panel-backdrop.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-section-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #8a8a99;
|
||||
}
|
||||
|
||||
.panel-copy {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel-copy code {
|
||||
font-size: 12px;
|
||||
color: #cfe0f7;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #a0a0ae;
|
||||
}
|
||||
|
||||
.field em {
|
||||
font-style: normal;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
background: #0f0f13;
|
||||
border: 1px solid #33333d;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.field-narrow input {
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: #24242c;
|
||||
border: 1px solid #35353f;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2b2b34;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-deep);
|
||||
border-color: #5b83bd;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #547cb6;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
min-height: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel-status.is-error {
|
||||
color: #ffb1b1;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 8px 13px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
background: #1e1e25;
|
||||
border: 1px solid #33333d;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.is-on {
|
||||
color: #cfe0f7;
|
||||
background: rgba(74, 111, 165, 0.3);
|
||||
border-color: #4a6fa5;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #c6cada;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.url-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #cfe0f7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.url-list span.muted {
|
||||
font-weight: 500;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Short windows (a strip display above a door, or a small browser window): shed the NOW
|
||||
card's chrome rather than squeezing the title down to nothing. */
|
||||
@media (max-height: 460px) {
|
||||
.now {
|
||||
height: 62vh;
|
||||
min-height: 0;
|
||||
padding: calc(var(--pad) * 0.55) calc(var(--pad) * 0.9);
|
||||
}
|
||||
|
||||
.now-label {
|
||||
font-size: calc(var(--unit) * 0.8 * var(--scale));
|
||||
}
|
||||
|
||||
.now-meta {
|
||||
margin-top: calc(var(--unit) * 0.25);
|
||||
font-size: calc(var(--unit) * 1 * var(--scale));
|
||||
}
|
||||
|
||||
/* The meta line already reads "Slide 12 / 43", so the bar is the first thing to go. */
|
||||
.progress {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones and small tablets in portrait: give NOW less height so NEXT still shows. */
|
||||
@media (max-aspect-ratio: 3 / 4) {
|
||||
.now {
|
||||
height: 38vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.panel {
|
||||
width: 100vw;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user