Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user