ProdDash: new project with reference forks of ProdCom Listener and BandLineupDisplay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-08-30 10:09:41 -04:00
co-authored by Claude Fable 5
commit 150797389e
45 changed files with 18299 additions and 0 deletions
@@ -0,0 +1,233 @@
# Stage Now / Next
The ProPresenter **Now / Next** display, as its own program — the same feature that
lives in the Band Lineup app's right-hand column, but on its own so it can run on
studio machines, hallway TVs, green-room tablets, anywhere in the building.
Both programs share one engine: [`../propresenter-core`](../propresenter-core/index.js).
Fix a ProPresenter quirk there and both get the fix.
---
## The easiest way: let the Band Lineup app host it
If the backstage machine already runs **Band Lineup**, you don't need to start anything here.
Open the app, and in the **People** panel find **Studio Displays** → **Turn On**.
It then lists the addresses to use, best first, each with a **Copy** button. Open one on any
screen in the building and bookmark it. Nothing to install on those screens.
Because the app hands the display its own ProPresenter connection, there is exactly one
connection to ProPresenter no matter how many screens are watching, and the studios always
show precisely what the backstage screen shows. The connection is still set up in one place — the
app's ProPresenter section — and the studio screens' own settings are locked, with a note
saying so.
The displays are live whenever Band Lineup is open. Close the app and every screen says
**Display offline** and names the backstage computer, rather than quietly showing a frozen
lineup. They come back on their own when the app reopens — no reloading needed.
### The bookmark that keeps working
The first address in the list is `http://nownext.local:7654`. The app publishes that name
itself over mDNS, so the bookmark survives the backstage machine getting a new IP from DHCP.
It resolves on Macs, iPads and Windows 10/11.
Do **not** bookmark the computer's own name (`http://backstage-pc.local:7654`) — Windows can
*resolve* mDNS names but never *publishes* its own, so Macs and iPads get nothing back.
If `nownext.local` doesn't work on a particular screen (some managed networks and separate
VLANs block mDNS), use one of the numeric addresses below it. Once any screen has loaded
the page from an address, that address is relabelled **Confirmed working** and moves to the
top — so after the first studio connects, the app knows the right answer for your building.
For a permanently fixed address, ask IT for a DHCP reservation on the backstage machine, and
optionally a DNS name pointing at it. One caveat if you go the DNS route: the zone must not
end in `.local`, or Apple devices will route the lookup to mDNS and it will fail.
### If a studio screen can't load the page (Windows)
Almost always Windows Defender Firewall. Two things make this confusing:
- The prompt appears when the app **starts serving**, not when a studio first connects — so
the backstage machine looks perfectly fine on `localhost` while every other screen times out.
- If whoever answered the prompt wasn't a local admin, Windows creates **block** rules no
matter which button they pressed, and it never asks again.
The installer adds the allow rules for you, so a normal install is fine. If you're running
an unpacked build or the rules got lost, an admin can restore them:
```bash
netsh advfirewall firewall add rule name="Band Lineup Display (TCP)" dir=in action=allow program="C:\Program Files\Band Lineup\Band Lineup.exe" enable=yes profile=domain,private protocol=TCP
```
The app helps you spot this: if hosting is on and no screen has ever connected, the Studio
Displays status line says so and names the port to allow.
Other things that break a working setup: the backstage PC sleeping (turn off sleep on a machine
that hosts displays), and Windows flipping the network profile to *Public* — the allow rules
above only cover Domain and Private.
### Keep the studio screens awake
The page asks the browser for a screen wake lock, but browsers only grant that on a secure
(HTTPS) origin, and this is served over plain HTTP on the LAN — so the request is always
refused. Turn off screen sleep in the OS settings on any machine or tablet you leave running
as a dedicated display; the web page cannot do it for you.
---
## Security: what's protected, and what isn't
Be clear-eyed about this one, because the honest answer is unusual.
**Viewing is deliberately open.** Anyone who can reach the port can watch the page. There is no
password, and adding one was considered and rejected: a wall TV has to come back by itself
after a power cut with nobody standing there, and a login prompt, a key in the URL, or a
certificate warning all break exactly that. A key in the URL would also live forever in every
bookmark and in the address list volunteers copy into group chats.
**More importantly, gating this page would not raise the bar much.** ProPresenter's own network
API on the same LAN has no authentication at all, and unlike this page it is not read-only.
Anyone who can reach this display can already read the same service order straight from
ProPresenter — and put whatever they like on the main screen. **Network placement is the control
that actually covers both.**
### What is protected
- **Nothing can be changed over the network** while the Band Lineup app is hosting. The
connection routes return `409`, so a browser on the LAN cannot retarget or disconnect the
display. Standalone additionally requires `application/json`, a same-origin request, and a
recognised `Host` — which blocks the JavaScript-free form CSRF and DNS-rebinding writes that
are otherwise possible against a local HTTP server.
- **Hidden items stay hidden.** Anything flagged hidden in ProPresenter — cut songs, contingency
cues, staff-only slides — has its name stripped before it leaves the machine, not merely
skipped when drawing the page.
- **The playlist library is not exposed.** `/api/playlists` is refused while the app is hosting.
- **Connection limits.** Concurrent viewers are capped (32 total, 6 per device) so nobody can pile
up connections inside the app, and a screen that stops reading is dropped rather than buffered
forever. Established screens are never dropped to make room for a new one.
- **The mDNS name can't be stolen with one packet.** A claim on `nownext.local` is verified by
probing before this host stands aside, and it reclaims the name when the other host leaves.
### What is exposed
Song and sermon titles, slide numbers, and the upcoming service order — in the clear, over plain
HTTP, to anything that can reach the port. Also the ProPresenter machine's address and port, and
the host's LAN addresses. Decide whether an unannounced sermon title or a baptism in the order
matters to you before a service; if it does, the answer is the VLAN below, not a password.
### Where this should live on the network
- Backstage PC, ProPresenter, and the display screens on a **trusted A/V or management VLAN**.
- **Guest Wi-Fi kept off that VLAN.** This is the single highest-value control here.
- A **DHCP reservation** for the backstage machine — which you want anyway so bookmarks survive.
- If you ever set a ProPresenter network password, treat it as a LAN-visible string: it travels
in the query string of ProPresenter's own API on every poll, because that is the interface
ProPresenter offers.
---
## Run it standalone
For a machine that doesn't run Band Lineup — or to host the display without it — run the
server on its own. Every other screen just opens a web page.
```bash
cd propresenter-app
node server.js
```
Then open `http://localhost:7654` on that machine, press the **gear** (bottom-right,
appears when you move the mouse or tap), and enter the IP and port of the computer
running ProPresenter. That's it — the connection is stored on the server, so every
other screen that opens the page is already following along.
The startup log prints the addresses other screens should use, e.g.
`http://10.1.1.42:7654`. They're also listed in the settings panel under
**Other screens**.
Requirements: Node 18 or newer. No `npm install`, no dependencies.
### ProPresenter side
Turn the network API on in **ProPresenter ▸ Preferences ▸ Network**. Note the port
(1025 by default) and set a password there if you want one.
## Run it as an app window
For the machine that should *host* the display, there's an Electron wrapper that
starts the same server and shows the page in a window:
```bash
npm run kiosk
```
```bash
npm run kiosk -- --kiosk
```
The second form is true kiosk: no window chrome, fullscreen, for a dedicated screen.
Either way the server still serves the rest of the building.
## Options
| What | How |
| --- | --- |
| Different port | `PORT=9000 node server.js` |
| Lock the settings so viewers can't change the connection | `PP_DISPLAY_READONLY=1 node server.js` |
| Move where `config.json` lives (a NAS, a shared path) | `PP_DISPLAY_DATA_DIR=/path/to/dir node server.js` |
Per-screen looks — text size (Small → Huge), whether to show the playlist name, the
progress bar, and whether to tidy titles — are set in the gear panel and saved on
**that device only**, so a 65" TV and an iPad can each be sized for their own room.
Keyboard: `f` toggles fullscreen, `Esc` closes the settings panel.
## Running it as a service
Any process manager works since it's a plain Node script. For example, with `pm2`:
```bash
pm2 start server.js --name stage-now-next -- --port 7654
```
## What's in here
| File | Job |
| --- | --- |
| `server.js` | Serves the UI and pushes the live view-model to every browser over SSE. Standalone it polls ProPresenter itself; embedded it mirrors the client the Band Lineup app is already polling with |
| `config.js` | Reads/writes `config.json` (password obfuscated at rest — treat the file as a secret), and ranks which LAN address to bookmark |
| `mdns.js` | Publishes `nownext.local` so a bookmark survives a DHCP change. Zero dependencies |
| `public/index.html`, `public/app.js`, `public/styles.css` | The display itself and the settings panel |
| `main.js` | Optional Electron kiosk wrapper |
| `../propresenter-core/index.js` | The shared ProPresenter client — also used by the Band Lineup app |
## API
Anything on the LAN can read the live state, which makes it easy to hang other
displays or automations off the same feed:
| Endpoint | Returns |
| --- | --- |
| `GET /api/state` | `{ state, status }` — one JSON snapshot |
| `GET /api/stream` | Server-Sent Events: `state` on every poll, `status` on connection changes |
| `GET /api/status` | Connection info only |
| `GET /api/playlists` | Every playlist ProPresenter knows about |
| `GET /api/health` | `{ ok, viewers, reachable }` |
| `POST /api/connect` | `{ host, port, password? }` — tests, saves, and starts following |
| `POST /api/disconnect` | Stops following; keeps host/port for next time |
The two `POST` routes return `403` when `PP_DISPLAY_READONLY=1`, and `409` when the Band
Lineup app is hosting — a browser on the LAN must never be able to retarget or disconnect
the backstage app's own ProPresenter connection.
## Notes
- **The display auto-follows whatever ProPresenter is presenting** — the live playlist
and item come from ProPresenter itself, so there's nothing to pick each week.
- **Slide totals respect arrangements.** If an arrangement repeats a chorus, the total
counts the repeat, so "Slide 41 / 43" always matches what the operator sees.
- **Building a Windows installer** (`npm run dist`) needs `npm install` in this folder
first, and packages `../propresenter-core` alongside — keep the two folders siblings.
The everyday `node server.js` path needs neither step.
@@ -0,0 +1,279 @@
'use strict';
/**
* Config storage for the standalone Now/Next display.
*
* The file format matches the Band Lineup app's credential files (an `enc:` prefix
* for OS-encrypted secrets, `plain:` for base64) so the two programs stay readable
* to each other. Under Electron we use safeStorage; under plain `node server.js`
* there is no OS keychain hook, so the password is base64-obfuscated at rest —
* treat the config file as a secret either way.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const dgram = require('dgram');
const DEFAULT_CONFIG = {
enabled: false,
host: '',
port: 0,
password: '',
};
/** Electron's safeStorage when available (kiosk mode); null under plain Node. */
function safeStorage() {
try {
// eslint-disable-next-line global-require
const electron = require('electron');
if (electron?.safeStorage?.isEncryptionAvailable?.()) return electron.safeStorage;
} catch {
/* not running under Electron */
}
return null;
}
function encodeSecret(value) {
const text = String(value ?? '');
if (!text) return '';
const store = safeStorage();
if (store) return `enc:${store.encryptString(text).toString('base64')}`;
return `plain:${Buffer.from(text, 'utf8').toString('base64')}`;
}
function decodeSecret(value) {
const raw = String(value ?? '');
if (!raw) return '';
if (raw.startsWith('enc:')) {
const store = safeStorage();
if (!store) return ''; // written under Electron, now read by plain Node — can't decrypt
try {
return store.decryptString(Buffer.from(raw.slice(4), 'base64'));
} catch {
return '';
}
}
if (raw.startsWith('plain:')) return Buffer.from(raw.slice(6), 'base64').toString('utf8');
return raw;
}
/**
* Where config.json lives. Override with PP_DISPLAY_DATA_DIR (handy on a NAS or
* when several displays share one host). Electron callers pass app.getPath('userData').
*/
function resolveDataDir(preferred) {
const fromEnv = process.env.PP_DISPLAY_DATA_DIR;
if (fromEnv) return path.resolve(fromEnv);
if (preferred) return path.resolve(preferred);
return path.join(__dirname, 'data');
}
function configPath(dataDir) {
return path.join(dataDir, 'config.json');
}
function normalize(entry) {
const host = String(entry?.host || '').trim();
const portNum = Number.parseInt(String(entry?.port ?? ''), 10);
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
return {
enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port),
host,
port,
password: String(entry?.password || ''),
};
}
function loadConfig(dataDir) {
try {
const raw = JSON.parse(fs.readFileSync(configPath(dataDir), 'utf8'));
return normalize({
enabled: raw.enabled,
host: raw.host,
port: raw.port,
password: decodeSecret(raw.password),
});
} catch {
return { ...DEFAULT_CONFIG };
}
}
function saveConfig(dataDir, config) {
const clean = normalize(config);
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
configPath(dataDir),
JSON.stringify(
{
enabled: clean.enabled,
host: clean.host,
port: clean.port,
password: encodeSecret(clean.password),
},
null,
2,
),
'utf8',
);
return clean;
}
/* ------------------------------------------------------------------ *
* Which address should someone actually bookmark? *
* *
* An A/V machine backstage is the worst case: alongside the house LAN *
* LAN it often has a camera/Dante network, a VPN tunnel, and on Windows *
* Hyper-V/WSL switches that hand out plausible-looking 192.168.x *
* addresses. Listing every non-internal IPv4 unranked (what we used to *
* do) gives a volunteer five equal-looking URLs and no way to choose. *
* So: hard-filter the impossible ones, score the rest, and let two *
* stronger signals override the score — the OS's own default route, *
* and (best of all) the local address a real viewer actually reached *
* us on. *
* ------------------------------------------------------------------ */
/** Adapter names that are never the church LAN. Windows keys are friendly names. */
const VIRTUAL_IFACE = [
/vethernet/i, /hyper-?v/i, /\bwsl\b/i, /virtualbox/i, /vmware/i, /vmnet/i, /docker/i,
/tailscale/i, /zerotier/i, /wintun/i, /wireguard/i, /nordlynx/i, /openvpn/i, /^tap-/i,
/radmin/i, /hamachi/i, /npcap/i, /bluetooth/i, /teredo/i, /isatap/i, /wan miniport/i,
/loopback pseudo/i, /^utun/i, /^awdl/i, /^llw/i, /^bridge/i, /^ipsec/i, /^gif/i, /^stf/i,
];
/** Addresses that can never carry a bookmark, whatever adapter they came from. */
function isImpossibleAddress(address) {
if (/^169\.254\./.test(address)) return true; // DHCP failed on that NIC
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return true; // CGNAT / Tailscale
if (/^25\./.test(address)) return true; // Hamachi
if (/^192\.168\.56\./.test(address)) return true; // VirtualBox host-only default
return false;
}
function isPrivate(address) {
return /^10\./.test(address)
|| /^192\.168\./.test(address)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(address);
}
/** Locally-administered MAC bit — set on virtual/bridged adapters. */
function isLocallyAdministeredMac(mac) {
const firstOctet = Number.parseInt(String(mac || '').split(':')[0], 16);
return Number.isFinite(firstOctet) && (firstOctet & 0x02) !== 0;
}
function scoreAddress(entry, ifaceName) {
let score = 0;
if (/^192\.168\./.test(entry.address)) score += 40; // overwhelmingly the church-LAN shape
else if (/^10\./.test(entry.address)) score += 30;
else if (/^172\./.test(entry.address)) score += 20;
else score -= 60; // public or unrecognised
if (entry.netmask === '255.255.255.0') score += 10;
if (/^ethernet/i.test(ifaceName)) score += 8; // prefer wired on an A/V machine
else if (/wi-?fi|wlan|^en0$/i.test(ifaceName)) score += 4;
if (isLocallyAdministeredMac(entry.mac)) score -= 25;
return score;
}
/**
* Ranked candidate addresses for viewers, best first.
* @param {object} [hints]
* @param {string} [hints.defaultRoute] Address of the interface holding the default route.
* @param {string[]} [hints.observed] Local addresses real viewers have already connected to.
*/
function viewerAddresses(hints = {}) {
const defaultRoute = String(hints.defaultRoute || '');
const observed = new Set((hints.observed || []).filter(Boolean));
const out = [];
const interfaces = os.networkInterfaces();
for (const [ifaceName, entries] of Object.entries(interfaces)) {
if (VIRTUAL_IFACE.some((pattern) => pattern.test(ifaceName))) continue;
for (const entry of entries || []) {
// Node <18 reported family as a string, newer as the number 4 — accept both.
const isIPv4 = entry.family === 'IPv4' || entry.family === 4;
if (!isIPv4 || entry.internal) continue;
if (isImpossibleAddress(entry.address)) continue;
if (entry.mac === '00:00:00:00:00:00') continue; // tunnel / pseudo adapter
let score = scoreAddress(entry, ifaceName);
if (entry.address === defaultRoute) score += 100; // the OS's own answer beats any heuristic
const confirmed = observed.has(entry.address);
if (confirmed) score += 1000; // a real viewer reached us here
out.push({
address: entry.address,
iface: ifaceName,
netmask: entry.netmask,
private: isPrivate(entry.address),
confirmed,
score,
});
}
}
out.sort((a, b) => b.score - a.score || a.address.localeCompare(b.address));
return out;
}
/**
* The address on the interface that holds the default route, per the OS routing table.
* A UDP "connect" sends no packets — it only asks the kernel which source address it
* would use — so this is free and side-effect-less.
*/
function defaultRouteAddress() {
return new Promise((resolve) => {
let settled = false;
const done = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
let socket;
try {
socket = dgram.createSocket('udp4');
} catch {
done('');
return;
}
socket.once('error', () => {
try { socket.close(); } catch { /* already closed */ }
done('');
});
const timer = setTimeout(() => {
try { socket.close(); } catch { /* already closed */ }
done('');
}, 400);
timer.unref?.();
try {
socket.connect(53, '203.0.113.1', () => { // RFC 5737 documentation address; nothing is sent
let address = '';
try { address = socket.address().address || ''; } catch { /* ignore */ }
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
done(address);
});
} catch {
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
done('');
}
});
}
/** Legacy helper: flat list of plausible addresses, best first. */
function lanAddresses() {
return viewerAddresses().map((entry) => entry.address);
}
module.exports = {
DEFAULT_CONFIG,
resolveDataDir,
configPath,
loadConfig,
saveConfig,
normalize,
lanAddresses,
viewerAddresses,
defaultRouteAddress,
};
@@ -0,0 +1,143 @@
'use strict';
/**
* Stage Now / Next — Electron kiosk wrapper.
*
* Optional. The display itself is just a web page served by server.js, so a studio
* can simply open the URL in a browser. This wrapper exists for the machine that
* should *host* the display: it starts the same server in-process and puts the page
* in a borderless window, while still serving every other screen on the network.
*
* npm run kiosk → window on the host machine
* npm run kiosk -- --kiosk → true kiosk (no window chrome, always fullscreen)
*/
const { app, BrowserWindow, Menu, shell, dialog } = require('electron');
const { createDisplayServer, DEFAULT_PORT } = require('./server');
/** How many ports to try past the preferred one before giving up. */
const PORT_ATTEMPTS = 5;
let mainWindow = null;
let display = null;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
app.whenReady().then(async () => {
try {
display = await startServer();
} catch (error) {
dialog.showErrorBox(
'Stage Now / Next',
`Could not start the display server.\n\n${error instanceof Error ? error.message : String(error)}`,
);
app.quit();
return;
}
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', async () => {
if (display) await display.close().catch(() => {});
});
}
async function startServer() {
const preferred = Number.parseInt(process.env.PORT || '', 10) || DEFAULT_PORT;
let lastError = null;
for (let attempt = 0; attempt < PORT_ATTEMPTS; attempt += 1) {
const candidate = createDisplayServer({
port: preferred + attempt,
dataDir: app.getPath('userData'),
});
try {
await candidate.listen();
return candidate;
} catch (error) {
await candidate.close().catch(() => {});
lastError = error;
// Another display (or anything else) already holds that port — step to the next one.
if (error?.code !== 'EADDRINUSE') throw error;
}
}
throw lastError || new Error('No free port found.');
}
function createWindow() {
const kiosk = process.argv.includes('--kiosk');
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
backgroundColor: '#0f0f12',
autoHideMenuBar: true,
kiosk,
fullscreen: kiosk,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
});
buildMenu();
mainWindow.loadURL(`http://127.0.0.1:${display.port}/`);
// Anything that tries to open a new window (there is nothing today) goes to the real browser.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function buildMenu() {
const urls = display.status().viewerUrls;
const template = [
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
{
label: 'Display',
submenu: [
{
label: 'Toggle Fullscreen',
accelerator: process.platform === 'darwin' ? 'Ctrl+Cmd+F' : 'F11',
click: () => mainWindow?.setFullScreen(!mainWindow.isFullScreen()),
},
{ role: 'reload' },
{ type: 'separator' },
{
label: 'Copy Address For Other Screens',
enabled: urls.length > 0,
click: () => {
const { clipboard } = require('electron');
clipboard.writeText(urls[0]);
},
},
{
label: urls.length ? `Serving ${urls.join(' · ')}` : 'No network address detected',
enabled: false,
},
{ type: 'separator' },
{ role: 'quit' },
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
@@ -0,0 +1,570 @@
'use strict';
/**
* A minimal multicast-DNS responder — just enough to make one name resolve.
*
* WHY THIS EXISTS: a bookmark has to survive DHCP handing the host a new address.
* The obvious answer, "bookmark the computer's name", does not work here: Windows
* ships an mDNS *resolver* but no *responder*, so it never publishes its own
* <name>.local, and an iPad or Mac asking for it gets silence. (Windows-to-Windows
* appears to work only via LLMNR/NetBIOS, which Microsoft is ramping down.)
* So the app publishes its OWN name — nownext.local by default — which macOS, iOS
* and Windows 10 1703+/11 all resolve natively.
*
* Scope on purpose: one A record for one name. No service discovery (_http._tcp),
* no PTR/SRV/TXT, no full RFC 6762 probing state machine. Zero dependencies.
*
* Limits worth knowing: mDNS is link-local (TTL 1) and does not cross VLANs or
* subnets, and it needs inbound UDP 5353 through the firewall. The ranked IP
* addresses remain the fallback for anywhere this can't reach.
*/
const dgram = require('dgram');
const os = require('os');
const MDNS_ADDRESS = '224.0.0.251';
const MDNS_PORT = 5353;
const TYPE_A = 1;
const TYPE_ANY = 255;
const CLASS_IN = 1;
/** Set on unique records so resolvers replace rather than accumulate. */
const CACHE_FLUSH = 0x8000;
const DEFAULT_TTL = 120;
function ipToInt(address) {
const parts = String(address).split('.');
if (parts.length !== 4) return null;
let value = 0;
for (const part of parts) {
const octet = Number.parseInt(part, 10);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
value = (value * 256) + octet;
}
return value;
}
function sameSubnet(addressA, addressB, netmask) {
const a = ipToInt(addressA);
const b = ipToInt(addressB);
const mask = ipToInt(netmask);
if (a === null || b === null || mask === null) return false;
// >>> 0 keeps the result unsigned; a /0 mask would match everything, so guard it.
return mask !== 0 && ((a & mask) >>> 0) === ((b & mask) >>> 0);
}
/** Encode a dotted name as length-prefixed DNS labels. */
function encodeName(name) {
const labels = String(name).replace(/\.$/, '').split('.');
const parts = [];
for (const label of labels) {
const bytes = Buffer.from(label, 'utf8');
if (bytes.length > 63) throw new Error(`mDNS label too long: ${label}`);
parts.push(Buffer.from([bytes.length]), bytes);
}
parts.push(Buffer.from([0]));
return Buffer.concat(parts);
}
/**
* Read a DNS name at `offset`, following compression pointers.
* Returns the name plus the offset just past the name in the ORIGINAL position.
*/
function decodeName(buffer, offset) {
const labels = [];
let cursor = offset;
let end = -1;
let hops = 0;
while (cursor < buffer.length) {
const length = buffer[cursor];
if (length === 0) {
cursor += 1;
break;
}
if ((length & 0xc0) === 0xc0) {
// Compression pointer: follow it, but the caller continues after the pointer.
if (cursor + 1 >= buffer.length) return null;
if (end === -1) end = cursor + 2;
cursor = ((length & 0x3f) << 8) | buffer[cursor + 1];
hops += 1;
if (hops > 16) return null; // malformed / pointer loop
continue;
}
if (cursor + 1 + length > buffer.length) return null;
labels.push(buffer.toString('utf8', cursor + 1, cursor + 1 + length));
cursor += 1 + length;
}
return { name: labels.join('.'), offset: end === -1 ? cursor : end };
}
/** Parse the question section of a query. Returns null for anything malformed. */
function decodeQuestions(buffer) {
if (buffer.length < 12) return null;
const flags = buffer.readUInt16BE(2);
if ((flags & 0x8000) !== 0) return { isResponse: true, questions: [] }; // a response, not a query
const count = buffer.readUInt16BE(4);
if (count === 0 || count > 32) return { isResponse: false, questions: [] };
const questions = [];
let offset = 12;
for (let i = 0; i < count; i += 1) {
const decoded = decodeName(buffer, offset);
if (!decoded) return null;
offset = decoded.offset;
if (offset + 4 > buffer.length) return null;
const type = buffer.readUInt16BE(offset);
const rawClass = buffer.readUInt16BE(offset + 2);
offset += 4;
questions.push({
name: decoded.name,
type,
class: rawClass & 0x7fff,
// The top bit of QCLASS is the "unicast response wanted" flag (RFC 6762 §5.4).
wantsUnicast: (rawClass & 0x8000) !== 0,
});
}
return { isResponse: false, questions };
}
/**
* Parse the answer section of a response, returning the A records only.
* Needed for conflict detection: enterprise APs and mesh routers commonly REFLECT mDNS,
* so our own announcement comes back from the reflector's address. Matching on raw bytes
* flags that as a name clash; comparing the actual advertised addresses does not.
*/
function decodeAnswers(buffer) {
if (buffer.length < 12) return [];
const answerCount = buffer.readUInt16BE(6);
if (answerCount === 0 || answerCount > 64) return [];
const questionCount = buffer.readUInt16BE(4);
let offset = 12;
// Skip the question section, if the responder echoed it.
for (let i = 0; i < questionCount; i += 1) {
const decoded = decodeName(buffer, offset);
if (!decoded) return [];
offset = decoded.offset + 4;
}
const answers = [];
for (let i = 0; i < answerCount; i += 1) {
const decoded = decodeName(buffer, offset);
if (!decoded) break;
offset = decoded.offset;
if (offset + 10 > buffer.length) break;
const type = buffer.readUInt16BE(offset);
const rdLength = buffer.readUInt16BE(offset + 8);
offset += 10;
if (offset + rdLength > buffer.length) break;
if (type === TYPE_A && rdLength === 4) {
answers.push({
name: decoded.name,
address: `${buffer[offset]}.${buffer[offset + 1]}.${buffer[offset + 2]}.${buffer[offset + 3]}`,
});
}
offset += rdLength;
}
return answers;
}
/** Build a standard mDNS query for one name (type A, class IN). */
function buildQuery(name) {
const header = Buffer.alloc(12);
header.writeUInt16BE(0, 0); // mDNS queries use id 0
header.writeUInt16BE(0, 2); // standard query
header.writeUInt16BE(1, 4); // one question
const question = Buffer.alloc(4);
question.writeUInt16BE(TYPE_A, 0);
question.writeUInt16BE(CLASS_IN, 2);
return Buffer.concat([header, encodeName(name), question]);
}
/** Build a response packet carrying one A record per address. */
function buildResponse(name, addresses, ttl, queryId) {
const encodedName = encodeName(name);
const answers = addresses.map((address) => {
const rdata = Buffer.from(String(address).split('.').map((octet) => Number.parseInt(octet, 10)));
const record = Buffer.alloc(10);
record.writeUInt16BE(TYPE_A, 0);
record.writeUInt16BE(CLASS_IN | CACHE_FLUSH, 2);
record.writeUInt32BE(ttl, 4);
record.writeUInt16BE(4, 8);
return Buffer.concat([encodedName, record, rdata]);
});
const header = Buffer.alloc(12);
header.writeUInt16BE(queryId || 0, 0);
header.writeUInt16BE(0x8400, 2); // QR=1 (response), AA=1 (authoritative)
header.writeUInt16BE(0, 4); // no question echoed back
header.writeUInt16BE(answers.length, 6);
header.writeUInt16BE(0, 8);
header.writeUInt16BE(0, 10);
return Buffer.concat([header, ...answers]);
}
/**
* @param {object} [options]
* @param {string} [options.name] Bare label to publish; ".local" is appended. Default "nownext".
* @param {() => string[]} [options.getAddresses] Current IPv4 addresses to advertise, best first.
* @param {(message: string) => void} [options.log]
*/
function createMdnsResponder(options = {}) {
const label = String(options.name || 'nownext').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase() || 'nownext';
const fqdn = `${label}.local`;
const ttl = Number.isFinite(options.ttl) && options.ttl > 0 ? options.ttl : DEFAULT_TTL;
const getAddresses = typeof options.getAddresses === 'function' ? options.getAddresses : () => [];
const log = typeof options.log === 'function' ? options.log : () => {};
let socket = null;
let started = false;
let announceTimer = null;
let lastError = '';
let conflictHost = '';
/** True while we have stood down from the name because another host really holds it. */
let relinquished = false;
/** In-flight conflict verification: { foreign: string } while probing, else null. */
let probe = null;
let recoveryTimer = null;
/** Interfaces we can advertise on, as {address, netmask}. */
function localInterfaces() {
const out = [];
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries || []) {
const isIPv4 = entry.family === 'IPv4' || entry.family === 4;
if (isIPv4 && !entry.internal) out.push({ address: entry.address, netmask: entry.netmask });
}
}
return out;
}
/**
* Which of our addresses to hand a particular asker. On a multi-homed A/V machine
* (house LAN + camera network) answering with everything would send a studio PC an
* address it cannot route to, so prefer addresses on the asker's own subnet.
*/
function addressesForPeer(peerAddress) {
const advertised = getAddresses().filter(Boolean);
if (!advertised.length) return [];
const interfaces = localInterfaces();
const onPeerSubnet = advertised.filter((address) => {
const match = interfaces.find((entry) => entry.address === address);
return match && sameSubnet(peerAddress, address, match.netmask);
});
return onPeerSubnet.length ? onPeerSubnet : advertised;
}
function send(message, port, address) {
if (!socket) return;
try {
socket.send(message, port, address, (err) => {
if (err) lastError = err.message;
});
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
/** Aim the next multicast send out of a specific interface. Best effort. */
function setOutgoingInterface(address) {
try {
socket.setMulticastInterface(address);
return true;
} catch {
return false;
}
}
/**
* Announce (or, with ttlValue 0, withdraw) the name.
*
* One packet PER INTERFACE, each advertising only that interface's own address. Sending
* every address to every interface is the tempting shortcut and it is wrong: a studio PC
* would cache all of them, and browsers try cached addresses in turn — so a machine that
* can only route to the house LAN might sit waiting on the camera-network address first.
* A listener should only ever learn the address it can actually reach us on.
*/
function announce(ttlValue = ttl) {
const advertised = getAddresses().filter(Boolean);
if (!advertised.length) return;
const interfaces = localInterfaces();
let sent = 0;
for (const address of advertised) {
if (!interfaces.some((entry) => entry.address === address)) continue;
if (!setOutgoingInterface(address)) continue;
send(buildResponse(fqdn, [address], ttlValue, 0), MDNS_PORT, MDNS_ADDRESS);
sent += 1;
}
if (!sent) {
// No interface would take the hint — fall back to one packet on the default route.
setOutgoingInterface('0.0.0.0');
send(buildResponse(fqdn, advertised, ttlValue, 0), MDNS_PORT, MDNS_ADDRESS);
}
setOutgoingInterface('0.0.0.0');
}
function handleMessage(buffer, rinfo) {
let parsed;
try {
parsed = decodeQuestions(buffer);
} catch {
return; // never let a malformed packet from the network throw
}
if (!parsed) return;
if (parsed.isResponse) {
// A genuine clash is another host advertising OUR name with an address that is not
// ours. A reflected copy of our own announcement carries only our own addresses, so
// it no longer trips this.
const mine = new Set(localInterfaces().map((entry) => entry.address));
for (const answer of decodeAnswers(buffer)) {
if (answer.name.toLowerCase() !== fqdn) continue;
if (mine.has(answer.address)) continue;
// Only believe a claim from something on one of our own subnets. An off-link
// packet naming our record is a spoof, not a neighbour.
if (!onOurLink(rinfo.address)) continue;
if (probe) {
probe.foreign = answer.address; // a probe is running: this answers it
} else if (!relinquished) {
void verifyThenConcede(answer.address);
}
break;
}
return;
}
// Once the name is contested we must not answer for it (RFC 6762 §9). Two responders
// both answering is worse than neither: the name resolves to a different machine each
// time, so half the screens land on the wrong host and show nothing.
if (relinquished) return;
for (const question of parsed.questions) {
if (question.name.toLowerCase() !== fqdn) continue;
if (question.type !== TYPE_A && question.type !== TYPE_ANY) continue;
if (question.class !== CLASS_IN && question.class !== TYPE_ANY) continue;
const addresses = addressesForPeer(rinfo.address);
if (!addresses.length) continue;
const queryId = buffer.readUInt16BE(0);
// Legacy resolvers (source port != 5353) and QU queries want a direct reply.
if (question.wantsUnicast || rinfo.port !== MDNS_PORT) {
send(buildResponse(fqdn, addresses, ttl, queryId), rinfo.port, rinfo.address);
} else {
// Reply out of the interface the answer is valid for, so nobody else on another
// network caches an address they cannot reach.
setOutgoingInterface(addresses[0]);
send(buildResponse(fqdn, addresses, ttl, 0), MDNS_PORT, MDNS_ADDRESS);
setOutgoingInterface('0.0.0.0');
}
}
}
/** Is this address on one of our own subnets? */
function onOurLink(address) {
return localInterfaces().some((entry) => sameSubnet(address, entry.address, entry.netmask));
}
/**
* Ask whether anyone else is holding our name (RFC 6762 §8.1-style probing: three
* queries, 250ms apart). Resolves with the foreign address, or '' if the link is quiet.
*
* This is the difference between a robust responder and a one-packet kill switch. A
* genuine second host answers a direct question; a spoofer who fires one unsolicited
* packet and leaves does not.
*/
function probeForConflict() {
return new Promise((resolve) => {
if (!socket) {
resolve('');
return;
}
probe = { foreign: '' };
const query = buildQuery(fqdn);
let sent = 0;
const tick = () => {
if (!socket || !probe) return;
if (probe.foreign || sent >= 3) {
const found = probe ? probe.foreign : '';
probe = null;
resolve(found);
return;
}
sent += 1;
send(query, MDNS_PORT, MDNS_ADDRESS);
const next = setTimeout(tick, 250);
next.unref?.();
};
tick();
// Give the third query time to be answered before declaring the link quiet.
const done = setTimeout(() => {
const found = probe ? probe.foreign : '';
probe = null;
resolve(found);
}, 1200);
done.unref?.();
});
}
/**
* A neighbour appears to hold our name. Verify it before standing down, then keep
* checking so the name comes back when they go away.
*
* Deliberately NOT the RFC's rename-on-conflict: the URL is the whole point of this
* feature, and renaming would break every bookmark in the building — and would hand a
* spoofer the good name. Standing aside temporarily keeps resolution unambiguous while
* the numeric addresses (which the host app lists) keep working throughout.
*/
async function verifyThenConcede(claimedBy) {
if (relinquished || probe) return;
const confirmed = await probeForConflict();
if (!confirmed) {
log(`ignored an unverified claim on ${fqdn} (nobody answered a probe) — keeping the name`);
return;
}
conflictHost = confirmed;
relinquished = true;
log(`another host at ${confirmed} really holds ${fqdn} — standing down until it leaves`);
if (announceTimer) {
clearInterval(announceTimer);
announceTimer = null;
}
try {
announce(0); // TTL 0: resolvers drop OUR records so the name stops being ambiguous
} catch {
/* nothing more we can do */
}
startRecoveryWatch();
}
/** While stood down, re-probe periodically and reclaim the name once the link is quiet. */
function startRecoveryWatch() {
if (recoveryTimer) return;
recoveryTimer = setInterval(async () => {
if (!socket || !relinquished) return;
const stillThere = await probeForConflict();
if (stillThere) return;
log(`${fqdn} is free again — reclaiming it`);
relinquished = false;
conflictHost = '';
clearInterval(recoveryTimer);
recoveryTimer = null;
announce();
announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000);
announceTimer.unref?.();
}, 60000);
recoveryTimer.unref?.();
}
function start() {
if (started) return Promise.resolve({ ok: true, name: fqdn });
started = true;
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
try {
// reuseAddr is essential: macOS already runs mDNSResponder on 5353 and we must
// coexist with it (it will not answer for our name, so there is no conflict).
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
started = false;
finish({ ok: false, name: fqdn, error: lastError });
return;
}
socket.on('error', (err) => {
lastError = err instanceof Error ? err.message : String(err);
log(`socket error: ${lastError}`);
try { socket.close(); } catch { /* already closed */ }
socket = null;
started = false;
finish({ ok: false, name: fqdn, error: lastError });
});
socket.on('message', (buffer, rinfo) => {
try {
handleMessage(buffer, rinfo);
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
});
socket.bind({ port: MDNS_PORT, exclusive: false }, () => {
try {
socket.setMulticastTTL(255); // RFC 6762 §11
} catch { /* not fatal */ }
let joined = 0;
for (const entry of localInterfaces()) {
try {
socket.addMembership(MDNS_ADDRESS, entry.address);
joined += 1;
} catch {
/* some adapters refuse multicast; others still work */
}
}
if (!joined) {
try {
socket.addMembership(MDNS_ADDRESS);
joined = 1;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
// Announce twice a second apart (RFC 6762 §8.3 asks for repeats), then hourly-ish
// so a resolver that missed both still learns the name.
announce();
const second = setTimeout(announce, 1000);
second.unref?.();
announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000);
announceTimer.unref?.();
finish({ ok: joined > 0, name: fqdn, interfaces: joined, error: joined ? '' : lastError });
});
});
}
function stop() {
if (announceTimer) {
clearInterval(announceTimer);
announceTimer = null;
}
if (recoveryTimer) {
clearInterval(recoveryTimer);
recoveryTimer = null;
}
probe = null;
if (socket) {
// Goodbye: TTL 0 tells resolvers to forget the name immediately, so a screen opened
// right after we stop fails fast instead of hanging on a stale cache entry.
try {
announce(0);
} catch { /* going away anyway */ }
try { socket.close(); } catch { /* already closed */ }
socket = null;
}
started = false;
}
return {
start,
stop,
get name() { return fqdn; },
get url() { return fqdn; },
isRunning: () => Boolean(socket),
/** True only while we still own the name and are answering for it. */
isPublishing: () => Boolean(socket) && !relinquished,
getLastError: () => lastError,
getConflictHost: () => conflictHost,
};
}
module.exports = {
createMdnsResponder,
MDNS_PORT,
// exported for tests
buildQuery,
encodeName,
decodeName,
decodeQuestions,
decodeAnswers,
buildResponse,
sameSubnet,
};
@@ -0,0 +1,48 @@
{
"name": "propresenter-now-next",
"productName": "Stage Now / Next",
"version": "1.3.6",
"buildNumber": "14",
"description": "Standalone ProPresenter Now / Next display for studios and hallway screens",
"main": "main.js",
"scripts": {
"start": "node server.js",
"kiosk": "../node_modules/.bin/electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder --win --x64"
},
"license": "MIT",
"devDependencies": {
"electron": "^33.2.0",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "com.band.propresenter.nownext",
"productName": "Stage Now Next",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"server.js",
"config.js",
"public/**/*",
"../propresenter-core/**/*"
],
"win": {
"target": [
"nsis",
"portable"
]
},
"mac": {
"target": [
"dmg"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
@@ -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;
}
}
@@ -0,0 +1,790 @@
'use strict';
/**
* The ProPresenter Now / Next display server.
*
* Runs in two modes:
*
* STANDALONE — `node server.js`. Owns its own ProPresenter connection, stored in
* config.json, configurable from any browser that opens the page.
*
* EMBEDDED — the Band Lineup app passes in the client it is already polling with
* (`createDisplayServer({ client })`). One poll loop feeds Band Lineup's own column and
* every screen in the building, the band app's UI is the only place the connection is
* configured, and this server touches no config file at all. In this mode it must never
* stop or reconfigure the client it was handed — doing so would kill the backstage display
* mid-service.
*
* Either way, browsers get the live view-model over Server-Sent Events, so one install
* can drive every screen: studios, hallway TVs, green-room tablets.
*
* Zero npm dependencies: Node built-ins only (needs Node 18+ for global fetch).
*/
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createProPresenterClient, clearedViewModel, DEFAULT_PP_PORT } = require('../propresenter-core');
const {
resolveDataDir,
configPath,
loadConfig,
saveConfig,
viewerAddresses,
defaultRouteAddress,
} = require('./config');
const { createMdnsResponder } = require('./mdns');
const DEFAULT_PORT = 7654;
const PUBLIC_DIR = path.join(__dirname, 'public');
/** SSE comment ping cadence — keeps idle connections alive through proxies and sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Cap on remembered viewer-facing local addresses, so a hostile client can't grow it forever. */
const MAX_OBSERVED = 16;
/**
* Per-viewer write buffer we tolerate before dropping them. A tablet that locks or a PC that
* enters modern standby keeps the TCP connection open but stops reading, and res.write()
* returns false rather than throwing — so without this every frame queues in the main
* process forever (measured: ~78 MB RSS after two hours of polling for one dead viewer).
* A healthy screen is brought straight back by the page's own reconnect logic.
*/
const MAX_VIEWER_BACKLOG = 256 * 1024;
/**
* Caps on live viewers. A building has a dozen screens at most; without a limit anyone who
* can open sockets can pile up connections inside the Electron main process (300 were
* accepted before this). Excess connections are REFUSED — an established screen is never
* dropped to make room for a new one, because the established one is the one someone is
* watching.
*/
const MAX_STREAMS = 32;
const MAX_STREAMS_PER_IP = 6;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.woff2': 'font/woff2',
};
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(payload);
}
function readBody(req, limitBytes = 64 * 1024) {
return new Promise((resolve, reject) => {
let data = '';
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > limitBytes) {
reject(new Error('Request body too large.'));
req.destroy();
return;
}
data += chunk;
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
/**
* Build the display server.
* @param {object} [options]
* @param {number} [options.port] TCP port to listen on.
* @param {object} [options.client] An existing ProPresenter client to mirror (EMBEDDED mode).
* @param {string} [options.hostKind] Machine-readable host id, e.g. 'band-lineup'.
* @param {string} [options.hostLabel] Human phrase for the host, shown to viewers.
* @param {string} [options.mdnsName] Bare label to publish over mDNS; '' disables it.
* @param {string} [options.dataDir] Where config.json is read/written (standalone only).
* @param {boolean} [options.readOnly] Refuse settings changes over HTTP.
*/
function createDisplayServer(options = {}) {
const port = Number.parseInt(String(options.port ?? process.env.PORT ?? DEFAULT_PORT), 10) || DEFAULT_PORT;
/** Presence of an injected client IS the mode switch. */
const embedded = Boolean(options.client);
const hostKind = String(options.hostKind || (embedded ? 'embedded' : 'standalone'));
const hostLabel = String(options.hostLabel || (embedded ? 'the computer hosting this display' : 'the display server'));
const mdnsName = options.mdnsName === undefined ? 'nownext' : String(options.mdnsName || '');
// Embedded callers pass the host app's version; standalone reads its own package.json.
const appVersion = String(options.appVersion || (() => {
try {
return require('./package.json').version || '';
} catch {
return '';
}
})());
// Standalone owns a config file; embedded owns nothing on disk (the band app persists the
// connection itself, so writing a second copy here would only invite the two to disagree).
const dataDir = embedded ? null : resolveDataDir(options.dataDir);
let config = embedded ? null : loadConfig(dataDir);
const readOnly = embedded ? true : (options.readOnly ?? (process.env.PP_DISPLAY_READONLY === '1'));
const readOnlyReason = embedded ? 'managed-by-host' : (readOnly ? 'locked' : '');
/** Latest view-model, served to new viewers immediately so screens never start blank. */
let latest = clearedViewModel();
/** Serialized copy of the last frame actually broadcast, for change detection. */
let lastStateFrame = '';
/** @type {Set<import('http').ServerResponse>} */
const streams = new Set();
/** Local addresses that real (non-loopback) viewers actually reached us on. */
const observed = new Set();
/**
* Whether any screen has connected since this server started. Latched HERE, where the
* fact is known, not sampled by a UI poll — that poll is suppressed for the whole of
* show mode, so a service where three studios watched for 90 minutes would otherwise
* look like one where nothing ever connected.
*/
let everHadViewer = false;
let peakViewers = 0;
let defaultRoute = '';
let heartbeat = null;
let listening = false;
let lastServerError = '';
let mdns = null;
const client = embedded
? options.client
: createProPresenterClient({
onData: (view) => {
publish(view);
},
});
if (!embedded) client.configure(config);
function broadcast(event, payload) {
if (!streams.size) return;
let frame;
try {
// Stringify OUTSIDE the per-client loop, and inside the guard: a serialization
// failure must not escape into the caller (in embedded mode the caller is the poll
// loop, and a throw there would end polling).
frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
} catch (err) {
lastServerError = err instanceof Error ? err.message : String(err);
return;
}
for (const res of streams) {
try {
const backlog = res.writableLength || res.socket?.writableLength || 0;
if (backlog > MAX_VIEWER_BACKLOG) {
streams.delete(res);
res.destroy();
continue;
}
res.write(frame);
} catch {
streams.delete(res);
}
}
}
/**
* Push a fresh view-model to every viewer. In embedded mode this is the ONLY writer of
* `latest` — the band app calls it from the poll loop's onData. Never throws.
*/
function publish(view) {
try {
latest = view;
// The poll loop emits every tick whether anything changed or not (~100 frames a
// minute). Sending only real changes saves each screen a steady few KB/s and keeps
// idle viewers genuinely idle. New viewers still get `latest` on connect.
let frame;
try {
frame = JSON.stringify(view);
} catch (err) {
lastServerError = err instanceof Error ? err.message : String(err);
return;
}
if (frame === lastStateFrame) return;
lastStateFrame = frame;
broadcast('state', view);
} catch (err) {
lastServerError = err instanceof Error ? err.message : String(err);
}
}
/** Push a fresh connection status to every viewer. Call after anything changes it. */
function publishStatus() {
try {
broadcast('status', publicStatus());
} catch (err) {
lastServerError = err instanceof Error ? err.message : String(err);
}
}
/** Ranked list of ways to reach this display, best first. */
function addressList() {
const out = [];
if (mdns && mdns.isPublishing()) {
out.push({
url: `http://${mdns.name}:${port}`,
kind: 'name',
confirmed: false,
detail: 'Best bookmark — survives an IP change.',
});
}
for (const entry of viewerAddresses({ defaultRoute, observed: [...observed] })) {
out.push({
url: `http://${entry.address}:${port}`,
kind: 'ip',
confirmed: entry.confirmed,
detail: entry.confirmed
? 'Confirmed working.'
: `Network adapter: ${entry.iface}`,
});
}
return out;
}
function publicStatus() {
const info = client.getConfig();
const addresses = addressList();
return {
host: info.host,
port: info.port || '',
enabled: info.enabled,
hasPassword: info.hasPassword,
reachable: latest.reachable,
readOnly,
readOnlyReason,
hostKind,
hostLabel,
hostVersion: appVersion,
// What to tell a viewer when ProPresenter is not connected. On a locked studio screen
// "press the gear and type an IP" is advice nobody there can act on.
setupHint: readOnly
? `Nobody needs to do anything here — the connection is set up on ${hostLabel}.`
: 'Press the gear, then enter the IP address and port of the ProPresenter computer.',
defaultProPresenterPort: DEFAULT_PP_PORT,
// Deliberately not published to the network: the absolute config path and the host's
// adapter names. The host app reads those in-process; a viewer has no use for them.
configPath: null,
viewers: streams.size,
// Screens, not sockets. One machine can hold more than one connection — a reloaded
// tab whose old stream has not been reaped yet, or two tabs of the same page — and
// reporting sockets makes the app claim more screens than exist in the building.
viewerDevices: new Set([...streams].map((res) => res.__viewerIp).filter(Boolean)).size,
everHadViewer,
peakViewers,
displayPort: port,
mdnsName: mdns && mdns.isPublishing() ? mdns.name : '',
mdnsConflict: mdns ? mdns.getConflictHost() : '',
addresses,
// Kept for older callers/scripts that read a flat list.
viewerUrls: addresses.filter((entry) => entry.kind === 'ip').map((entry) => entry.url),
};
}
function startPolling() {
if (embedded) throw new Error('startPolling() is not available in embedded mode.');
client.configure(config);
if (!client.start()) {
publish(clearedViewModel());
return false;
}
return true;
}
function stopPolling() {
if (embedded) throw new Error('stopPolling() is not available in embedded mode.');
client.stop();
publish(clearedViewModel());
}
function serveStatic(req, res, urlPath) {
let rel;
try {
rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, '');
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Bad request');
return;
}
// Resolve inside PUBLIC_DIR only — never let a crafted path escape the web root.
const target = path.resolve(PUBLIC_DIR, rel);
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Forbidden');
return;
}
fs.readFile(target, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not found');
return;
}
res.writeHead(200, {
'Content-Type': MIME[path.extname(target).toLowerCase()] || 'application/octet-stream',
// no-store, not no-cache: a wall screen must never run a stale page after an update.
// Paired with the page's own version-triggered reload, a new build reaches every
// screen within a poll or two without anyone touching them.
'Cache-Control': 'no-store, must-revalidate',
});
res.end(data);
});
}
/** True if an address belongs to this machine. */
function isOwnAddress(address) {
if (!address) return false;
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries || []) {
if (entry.address === address) return true;
}
}
return false;
}
/**
* Remember the local address a real viewer reached us on — that beats every ranking
* heuristic, because it is evidence rather than inference.
*
* Requests from this machine to its own LAN address do NOT count: they prove the socket
* is bound, not that anything else on the network can get through the firewall to it.
* Labelling those "confirmed working" would be exactly the false reassurance this is
* meant to replace.
*/
function noteViewerRoute(req) {
try {
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
if (!remote || remote.startsWith('127.') || remote === '::1') return;
if (isOwnAddress(remote)) return;
const local = String(req.socket?.localAddress || '').replace(/^::ffff:/, '');
if (!local || local.startsWith('127.') || local === '::1') return;
if (!observed.has(local) && observed.size < MAX_OBSERVED) observed.add(local);
} catch {
/* socket already gone */
}
}
/** Hosts we will accept a state-changing request for (blocks DNS-rebinding writes). */
function hostAllowedForWrite(hostHeader) {
const raw = String(hostHeader || '').trim().toLowerCase();
if (!raw) return false;
// Strip the port; reject anything that isn't a plain host[:port].
const host = raw.replace(/:\d+$/, '');
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') return true;
if (mdns && mdns.name && host === mdns.name.toLowerCase()) return true;
return viewerAddresses({ defaultRoute, observed: [...observed] }).some((entry) => entry.address === host);
}
/**
* Guard the two state-changing routes. Reads stay wide open — an unattended wall display
* must never be gated — but a write has to look like it came from this app's own page.
*
* Without this, a plain cross-site form works with no JavaScript and no preflight: a
* `<form enctype="text/plain">` can post a body that JSON.parse accepts, which is enough
* to retarget or disconnect a standalone display from any page a volunteer happens to open.
* Requiring application/json forces a CORS preflight that a hostile origin cannot pass.
*/
function refuseUnsafeWrite(req, res) {
const contentType = String(req.headers['content-type'] || '').toLowerCase();
if (!contentType.startsWith('application/json')) {
sendJson(res, 415, { error: 'Send application/json.' });
return true;
}
const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
if (site && site !== 'same-origin' && site !== 'none') {
sendJson(res, 403, { error: 'Cross-site requests are not allowed.' });
return true;
}
const origin = req.headers.origin;
if (origin) {
let originHost = '';
try {
originHost = new URL(origin).host;
} catch {
originHost = 'invalid';
}
if (!hostAllowedForWrite(originHost)) {
sendJson(res, 403, { error: 'Cross-site requests are not allowed.' });
return true;
}
}
if (!hostAllowedForWrite(req.headers.host)) {
sendJson(res, 403, { error: 'Unrecognised host name for a settings change.' });
return true;
}
return false;
}
async function handleApi(req, res, urlPath) {
if (urlPath === '/api/stream' && req.method === 'GET') {
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
if (streams.size >= MAX_STREAMS) {
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' });
res.end(JSON.stringify({ error: `This display is already serving ${streams.size} screens.` }));
return true;
}
let sameIp = 0;
for (const existing of streams) {
if (existing.__viewerIp === remote) sameIp += 1;
}
if (sameIp >= MAX_STREAMS_PER_IP) {
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' });
res.end(JSON.stringify({ error: 'Too many connections from this device.' }));
return true;
}
res.__viewerIp = remote;
// A browser that vanishes without closing the TCP connection (tab discarded, laptop
// lid shut, Wi-Fi dropped) leaves a socket the kernel will happily hold for many
// minutes, which inflates the count the operator reads. Keepalive probes make the
// OS notice and tear it down.
try {
req.socket.setKeepAlive(true, 15000);
} catch {
/* socket already gone */
}
// Build both payloads BEFORE writing headers: a throw after writeHead becomes an
// ERR_HTTP_HEADERS_SENT unhandled rejection, which in Electron takes the app down.
const stateFrame = `event: state\ndata: ${JSON.stringify(latest)}\n\n`;
const statusFrame = `event: status\ndata: ${JSON.stringify(publicStatus())}\n\n`;
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(stateFrame);
res.write(statusFrame);
streams.add(res);
everHadViewer = true;
peakViewers = Math.max(peakViewers, streams.size);
req.on('close', () => {
streams.delete(res);
// The viewer count changed; tell the remaining screens (and the host UI).
publishStatus();
});
publishStatus();
return true;
}
if (urlPath === '/api/state' && req.method === 'GET') {
sendJson(res, 200, { state: latest, status: publicStatus() });
return true;
}
if (urlPath === '/api/status' && req.method === 'GET') {
sendJson(res, 200, publicStatus());
return true;
}
if (urlPath === '/api/playlists' && req.method === 'GET') {
// The one route that had no guard. Nothing over HTTP needs it: the display page never
// calls it, and the host app reads playlists in-process over IPC. Left open it hands
// any unauthenticated client the entire ProPresenter library — not just today's order,
// but every playlist name, including anything held back or staff-only.
if (embedded || readOnly) {
sendJson(res, 409, {
error: `Playlists are managed on ${hostLabel}.`,
readOnlyReason,
});
return true;
}
try {
sendJson(res, 200, { playlists: await client.listPlaylists() });
} catch (error) {
sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not list playlists.' });
}
return true;
}
if (urlPath === '/api/connect' && req.method === 'POST') {
if (embedded) {
// Hard refuse. Half-completing this would retarget — or silently stop — the poll
// loop the backstage display itself depends on.
sendJson(res, 409, {
error: `This display follows ${hostLabel}. Change the ProPresenter connection there.`,
readOnlyReason,
});
return true;
}
if (readOnly) {
sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' });
return true;
}
if (refuseUnsafeWrite(req, res)) return true;
let payload = {};
try {
payload = JSON.parse((await readBody(req)) || '{}');
} catch {
sendJson(res, 400, { error: 'Malformed request.' });
return true;
}
const host = String(payload.host || '').trim();
const portNum = Number.parseInt(String(payload.port ?? ''), 10);
const ppPort = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
// An omitted password field keeps the stored one; an empty string clears it.
const password = payload.password === undefined ? config.password : String(payload.password);
if (!host || !ppPort) {
sendJson(res, 400, { error: 'Enter the ProPresenter host (IP) and port.' });
return true;
}
// Test on a throwaway client: pointing the live one at an unverified address would
// make every screen in the building flash "Reconnecting…" while an operator types.
const probe = createProPresenterClient();
probe.configure({ enabled: true, host, port: ppPort, password });
try {
await probe.testConnection();
} catch (error) {
sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not reach ProPresenter.' });
return true;
}
config = saveConfig(dataDir, { enabled: true, host, port: ppPort, password });
client.reset();
client.stop();
startPolling();
publishStatus();
sendJson(res, 200, { ok: true, status: publicStatus() });
return true;
}
if (urlPath === '/api/disconnect' && req.method === 'POST') {
if (embedded) {
sendJson(res, 409, {
error: `This display follows ${hostLabel}. Disconnect there instead.`,
readOnlyReason,
});
return true;
}
if (readOnly) {
sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' });
return true;
}
if (refuseUnsafeWrite(req, res)) return true;
stopPolling();
client.reset();
// Keep host/port on disk for an easy reconnect; drop the password.
config = saveConfig(dataDir, { enabled: false, host: config.host, port: config.port, password: '' });
client.configure({ ...config, password: '' });
publishStatus();
sendJson(res, 200, { ok: true, status: publicStatus() });
return true;
}
if (urlPath === '/api/health' && req.method === 'GET') {
sendJson(res, 200, { ok: true, viewers: streams.size, reachable: latest.reachable });
return true;
}
return false;
}
const server = http.createServer((req, res) => {
// Everything in here runs inside the Electron main process when embedded, so a single
// unhandled throw would take the whole band app down with it.
try {
noteViewerRoute(req);
let urlPath;
try {
// A malformed Host header makes the URL constructor throw. Don't trust it.
urlPath = new URL(req.url, 'http://display.invalid').pathname;
} catch {
sendJson(res, 400, { error: 'Bad request.' });
return;
}
if (urlPath.startsWith('/api/')) {
handleApi(req, res, urlPath)
.then((handled) => {
if (!handled) sendJson(res, 404, { error: 'Unknown endpoint.' });
})
.catch((error) => {
lastServerError = error instanceof Error ? error.message : String(error);
if (!res.headersSent) {
sendJson(res, 500, { error: 'Server error.' });
} else {
try { res.end(); } catch { /* already gone */ }
}
});
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { Allow: 'GET, HEAD' }).end('Method not allowed');
return;
}
serveStatic(req, res, urlPath);
} catch (error) {
lastServerError = error instanceof Error ? error.message : String(error);
try {
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
else res.end();
} catch { /* already gone */ }
}
});
// Post-listen errors (a dropped interface, an ECONNRESET storm) must be handled or Node
// treats them as uncaught. This has to be a persistent handler, not the one-shot reject
// used by listen(), which is already settled by then.
server.on('error', (error) => {
lastServerError = error instanceof Error ? error.message : String(error);
});
server.on('clientError', (error, socket) => {
lastServerError = error instanceof Error ? error.message : String(error);
try { socket.destroy(); } catch { /* already gone */ }
});
async function listen() {
defaultRoute = await defaultRouteAddress();
await new Promise((resolve, reject) => {
const onError = (error) => {
server.removeListener('listening', onListening);
reject(error);
};
const onListening = () => {
server.removeListener('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
// 0.0.0.0 on purpose: the whole point is other screens in the building can open this.
// NOTE on Windows: this bind is what triggers the Defender Firewall prompt — not the
// first remote connection. localhost keeps working either way, so the host machine
// can look perfectly healthy while every studio times out.
server.listen(port, '0.0.0.0');
});
listening = true;
heartbeat = setInterval(() => {
for (const res of streams) {
try {
if ((res.writableLength || res.socket?.writableLength || 0) > MAX_VIEWER_BACKLOG) {
streams.delete(res);
res.destroy();
continue;
}
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
if (mdnsName) {
mdns = createMdnsResponder({
name: mdnsName,
getAddresses: () => viewerAddresses({ defaultRoute, observed: [...observed] }).map((entry) => entry.address),
});
await mdns.start().catch(() => ({ ok: false }));
}
if (embedded) {
// Seed from the client we were handed so the first viewer sees the live service
// immediately instead of a blank card.
latest = client.getViewModel();
} else if (config.enabled) {
startPolling();
}
return { port, addresses: addressList(), mdnsName: mdns && mdns.isPublishing() ? mdns.name : '' };
}
async function close() {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
if (mdns) {
mdns.stop();
mdns = null;
}
// EMBEDDED: never touch the injected client. Stopping it here would silently kill the
// backstage Now/Next column every time hosting is switched off or the app quits.
if (!embedded) client.stop();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (!listening) return;
listening = false;
await new Promise((resolve) => {
// server.close() waits for every connection that isn't *idle* to finish, and a socket
// that connected without ever completing a request never becomes idle — which is
// exactly what browser speculative preconnect leaves behind (Safari on iPad, Chrome
// and Edge on the studio PCs all do it). close() would then never call back, wedging
// the caller: a port change would stop the old server and never start the new one.
// Node also cancels its own header/request timeouts during close, so nothing else
// reaps those sockets. Tear them down ourselves, with a hard bound as a backstop.
const bail = setTimeout(resolve, 2000);
bail.unref?.();
server.close(() => {
clearTimeout(bail);
resolve();
});
// Safe here: every SSE response was already ended above, so viewers see a clean
// stream end and reconnect on their own.
server.closeAllConnections?.();
});
}
return {
server,
listen,
close,
port,
dataDir,
readOnly,
embedded,
status: publicStatus,
addresses: addressList,
publish,
publishStatus,
viewers: () => streams.size,
isListening: () => listening,
getLastError: () => lastServerError,
client,
};
}
module.exports = { createDisplayServer, DEFAULT_PORT };
// Run directly: node server.js
if (require.main === module) {
const display = createDisplayServer();
display
.listen()
.then(({ port, addresses, mdnsName }) => {
const status = display.status();
console.log(`Stage Now / Next display on http://localhost:${port}`);
if (mdnsName) console.log(` bookmark this: http://${mdnsName}:${port}`);
for (const entry of addresses) console.log(` ${entry.url}${entry.confirmed ? ' (confirmed working)' : ''}`);
console.log(` config: ${display.dataDir ? configPath(display.dataDir) : '(none)'}`);
console.log(
status.enabled
? ` following ProPresenter at ${status.host}:${status.port}`
: ' no ProPresenter connection saved yet — open the page and press the gear to set one up',
);
if (status.readOnly) console.log(' settings are LOCKED (PP_DISPLAY_READONLY=1)');
})
.catch((error) => {
console.error(`Could not start the display server: ${error.message}`);
process.exit(1);
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
display.close().then(() => process.exit(0));
});
}
}