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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-08-30 10:09:41 -04:00
co-authored by Claude Fable 5
commit 150797389e
45 changed files with 18299 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
*.log
.env
+27
View File
@@ -0,0 +1,27 @@
# ProdDash
New web application for Waters Church production, to be developed in this repo.
The stack and structure of the new app are not yet decided — build it at the
repo root (or in an `app/` folder if that stays cleaner).
## Reference code (read-only forks — don't develop these here)
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
reconnect logic, channel colors/filtering, PWA install.
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
mic/pack labels) plus ProPresenter Now/Next:
- `band-lineup-display/propresenter-core/` — shared ProPresenter 7/20 client,
no deps, no Electron. All ProPresenter quirks live here (PP20 runtime route
discovery, arrangement-expanded slide totals, uuid-then-name live-item
matching). Reuse this rather than re-implementing ProPresenter access.
- `band-lineup-display/propresenter-app/` — standalone Now/Next web display.
- `band-lineup-display/tmp/pp-mock.js` — mock ProPresenter API for testing
without the real thing: `node tmp/pp-mock.js 1599`, then point at
`127.0.0.1:1599`.
Upstream sources: ProdCom Listener lives at
`github.com/michaelmcgary2008/prodcom-listener`; BandLineupDisplay exists only
on the production machine. These folders are snapshots, not submodules.
+27
View File
@@ -0,0 +1,27 @@
# ProdDash
A new web application, built on lessons and code from two existing Waters Church
production tools, which are included here as reference forks:
```
ProdDash/
├── prodcom-listener/ fork of ProdCom Listener — live transcript web app
│ (zero-dependency Node server + SSE proxy to the ProdCom API)
└── band-lineup-display/ fork of BandLineupDisplay — backstage stage-lineup Electron app,
plus the shared ProPresenter 7/20 client (propresenter-core/)
and the standalone Now/Next display (propresenter-app/)
```
The new application will live at the repo root as it takes shape; the two
reference folders are snapshots (build output and `node_modules` excluded) and
are not wired into a build.
## Reference apps at a glance
- **prodcom-listener** — serves a web UI and proxies `/prodcom/*` to the ProdCom
API, including the Server-Sent Events transcript stream, so browsers never
talk to ProdCom directly. See [prodcom-listener/README.md](prodcom-listener/README.md).
- **band-lineup-display** — Electron app for the backstage screen showing stage
lineup photos with mic/pack labels. Carries the ProPresenter **Now / Next**
feature; `propresenter-core/` is the single home for every ProPresenter
quirk. See [band-lineup-display/README.md](band-lineup-display/README.md).
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "checklist",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"cd /Users/Studio/Documents/recurring-checklist-app/server && ADMIN_PASSWORD=changeme REPORT_EMAIL='' PORT=4998 node server.js"
],
"port": 4998
},
{
"name": "ppdisplay",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"cd /Users/Studio/Documents/BandLineupDisplay/propresenter-app && node server.js"
],
"port": 7654
}
]
}
@@ -0,0 +1,6 @@
{
"permissions": {
"defaultMode": "auto"
},
"skipAutoPermissionPrompt": true
}
@@ -0,0 +1,163 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:openapi.propresenter.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Read(//private/tmp/**)",
"Bash(curl -sSL --max-time 60 \"https://raw.githubusercontent.com/jeffmikels/ProPresenter-API/master/pro7.9.openapi-spec.json\" -o pp.json)",
"Bash(python3 -)",
"Bash(node --check main.js)",
"Bash(node --check renderer.js)",
"Bash(node --check preload.js)",
"Bash(PP_STEP_MS=400 node tmp/pp-mock.js 1599)",
"Bash(node tmp/pp-verify.js 1599)",
"Bash(pkill -f \"tmp/pp-mock.js\")",
"Bash(PP_STEP_MS=600 node tmp/pp-mock.js 1599)",
"Bash(echo \"mock pid $!\")",
"Bash(curl -N --max-time 3 -s -X POST -H 'Content-Type: application/json' -d '[\"presentation/slide_index\"]' http://127.0.0.1:1599/v1/status/updates)",
"Bash(pkill -f \"tmp/pp-mock.js\" 2>/dev/null; sleep 0.3 *)",
"Bash(echo \"verify exit: $?\")",
"Bash(pkill -f \"electron .*pp-test-profile\")",
"Bash(npm run *)",
"Bash(tee /tmp/pp-build.log)",
"Bash(open -R \"/Users/Studio/Documents/BandLineupDisplay/dist/Band Lineup Setup 1.0.0.exe\")",
"Bash(tee /tmp/pp-build2.log)",
"Bash(tee /tmp/pp-build3.log)",
"Bash(tee /tmp/pp-build4.log)",
"Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/jeffmikels/ProPresenter-API/contents/\")",
"Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/bitfocus/companion-module-renewedvision-propresenter-api/contents/\")",
"Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/bitfocus/companion-module-renewedvision-propresenter-api/contents/src\")",
"Bash(curl -sSL --max-time 30 https://raw.githubusercontent.com/bitfocus/companion-module-renewedvision-propresenter-api/master/src/__TRACKED_VAR__.ts -o comp-__TRACKED_VAR__.ts)",
"Bash(curl -sSL --max-time 30 \"https://raw.githubusercontent.com/bitfocus/companion-module-renewedvision-propresenter-api/master/package.json\")",
"Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/package.json\")",
"Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/dist/index.js\" -o rvpro.js)",
"Bash(grep -noE \"playlistPlaylistIdGet[^\\\\n]{0,120}\" rvpro.js)",
"Bash(grep -noE \"/v1/playlist[^\\\\\"\\\\\\\\\\\\`]{0,60}\" rvpro.js)",
"Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/?meta\")",
"Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/dist/propresenter.js\" -o rvpro2.js)",
"Bash(grep -noE \"playlistPlaylistIdGet[\\\\s\\\\S]{0,200}\" rvpro2.js)",
"Bash(grep -noE \"v1/playlist[^\\\\\"\\\\\\\\\\\\`]{0,80}\" rvpro2.js)",
"Bash(curl -sSL --max-time 30 \"https://registry.npmjs.org/renewedvision-propresenter\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('latest:', d['dist-tags']\\); print\\('all versions:', list\\(d['versions'].keys\\(\\)\\)[-12:]\\)\")",
"Bash(tee /tmp/pp-build5.log)",
"Bash(node --check tmp/pp-mock.js)",
"Bash(PP_STEP_MS=5000 node tmp/pp-mock.js 1599)",
"Bash(node tmp/pp-verify-slides.js 1599)",
"Bash(tee /tmp/pp-build6.log)",
"Skill(update-config)",
"Skill(update-config:*)",
"Bash(echo \"=== ~/.claude/settings.json ===\"; cat ~/.claude/settings.json 2>/dev/null || echo \"\\(none\\)\" *)",
"Bash(PORT=3000 node server.js)",
"Bash(curl -s http://localhost:3000/healthz)",
"Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"?WaT3rs2016!\"}')",
"Bash(rm -f /Users/Studio/Documents/recurring-checklist-app/.claude/launch.json)",
"Bash(rmdir /Users/Studio/Documents/recurring-checklist-app/.claude)",
"Bash(mkdir -p /Users/Studio/Documents/BandLineupDisplay/.claude)",
"mcp__Claude_Preview__preview_start",
"Bash(tee /tmp/pp-build7.log)",
"Bash(tee /tmp/pp-build8.log)",
"Bash(tee /tmp/pp-build9.log)",
"Bash(tee /tmp/pp-build10.log)",
"Bash(node --check server.js)",
"Bash(node --check app.js)",
"WebFetch(domain:api.propresenter.com)",
"Bash(curl -s -o pp-spec.json \"https://raw.githubusercontent.com/jeffmikels/ProPresenter-API/master/pro7.9.openapi-spec.json\")",
"Bash(python3 -c ' *)",
"WebFetch(domain:api.github.com)",
"Bash(curl -s -L --max-time 20 \"https://api.github.com/search/code?q=group_identifiers+propresenter\" -H \"Accept: application/vnd.github+json\")",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d.get\\('total_count'\\)\\); [print\\(i['repository']['full_name'], i['path']\\) for i in d.get\\('items',[]\\)[:15]]\")",
"Bash(curl -s -L --max-time 15 \"https://openapi.propresenter.com/\")",
"Bash(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://openapi.propresenter.com/__TRACKED_VAR__)",
"Bash(curl -s -L --max-time 30 \"https://openapi.propresenter.com/swagger.json\" -o pp20-spec.json)",
"Bash(node --check public/app.js)",
"Bash(pkill -f \"node server.js\")",
"Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"changeme\"}')",
"Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).users.map\\(u=>u.username+' ['+u.location+'/'+u.team+'/'+u.role+']'\\).join\\('\\\\n'\\)\")",
"Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).lists.find\\(l=>l.id==='woonprod'\\).items.map\\(i=>i.title\\).join\\(', '\\)\")",
"Bash(node -pe ' *)",
"Bash(pkill -f \"pp-mock.js\")",
"Bash(node -e ' *)",
"Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).lists.find\\(l=>l.id==='global1'\\).items.map\\(i=>i.title\\).join\\(', '\\)\")",
"Bash(pkill -f \"node server.js\" 2>/dev/null *)",
"Bash(node -pe \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\); 'locations: '+d.locations.map\\(l=>l.name+'='+l.abbrev\\).join\\(', '\\)+'\\\\nteams: '+d.teams.map\\(t=>t.name+'='+t.abbrev\\).join\\(', '\\)\")",
"Bash(node -pe \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\); 'locations: '+d.locations.map\\(l=>l.name\\).join\\(', '\\)+'\\\\nteams: '+d.teams.map\\(t=>t.name\\).join\\(', '\\)\")",
"Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).teams.map\\(t=>t.name\\).join\\(', '\\)\")",
"Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"location\":\"executive\",\"password\":\"changeme\"}')",
"Bash(curl -sS -m 8 -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://10.1.1.5:3000/healthz)",
"Bash(curl -sS -m 8 http://10.1.1.5:3000/app.js)",
"Bash(curl -sS -m 8 http://10.1.1.5:3000/styles.css)",
"Bash(sed -n '/function openListModal/,/^ }/p' app.js)",
"Bash(sed -n '/function handleModalSave/,/^ }/p' app.js)",
"Bash(sed -n '/\\\\.list-tabs-row {/,/^}/p' styles.css)",
"Bash(sed -n '/^\\\\.list-menu-handle {/,/^}/p' styles.css)",
"Bash(node -c server.js)",
"Bash(node -c public/app.js)",
"Bash(DATA_DIR=/tmp/cl-test-data PORT=3999 ADMIN_PASSWORD=changeme node server.js)",
"Bash(echo \"started pid $!\")",
"Bash(curl -sf http://127.0.0.1:3999/healthz)",
"Bash(break)",
"Bash(node /tmp/cl-test.mjs)",
"Bash(echo \"exit: $?\")",
"Bash(kill 92507)",
"Bash(awk '/^\\\\.list-tab \\\\{/,/^}/' styles.css)",
"Bash(awk '/^\\\\.list-tab-loc/,/^}/' styles.css)",
"Bash(awk '/^\\\\.list-tab\\\\.active \\\\{/,/^}/' styles.css)",
"Bash(rm -rf /tmp/cl-preview-data /tmp/cl-server.log /tmp/cl-test-data *)",
"Bash(./build-deploy.sh)",
"Bash(kill 93146 93148 93160 93162)",
"Bash(pkill -f \"ssh.*10.1.1.5.*checklist-deploy\")",
"Bash(echo \"EXIT=$?\")",
"Bash(expect /tmp/sshstdin.exp 10.1.1.5 Christian Waters2010! ' *)",
"Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"REMOTE_PASS='Waters2010!' bash -s\" /tmp/verify.sh)",
"Bash(rm -f checklist-deploy.tgz /tmp/verify.sh /tmp/deploy2.log /tmp/sshstdin.exp /tmp/deploy.sh)",
"Bash(ls checklist-deploy.tgz)",
"Bash(awk '/^\\(html|body|\\\\.app-container|header|\\\\.main-content|\\\\.app-wrap\\)[ ,{]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)",
"Bash(awk '/^\\\\.modal-overlay[ ,{]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)",
"Bash(awk '/function openLogin/{p=1} p{print} p&&/^ }/{exit}' app.js)",
"Bash(grep -vE \"spawn|password:|chdir|^$\")",
"Bash(grep -vE \"spawn|chdir|^$\")",
"Bash(kill 96250 96254 96256)",
"Bash(pkill -f \"ssh.*10.1.1.5.*checklists.backup\")",
"Bash(pkill -f \"/tmp/sshbackup.exp\")",
"Bash(pkill -9 -f \"ssh.*10.1.1.5.*checklists.backup\")",
"Bash(awk '/^\\\\.list-tabs \\\\{/,/^}/' styles.css)",
"Bash(awk '/^\\\\.tab-view[ ,{.]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)",
"Bash(awk '/^\\\\.checklists-section \\\\{/,/^}/' styles.css)",
"Bash(awk '/^\\\\.checklist-column \\\\{/,/^}/' styles.css)",
"Bash(awk '/^\\\\.checklist-list \\\\{/,/^}/' styles.css)",
"Bash(rm -rf /tmp/cl-preview-data *)",
"Bash(grep -avE \"spawn|password:|chdir|^$\")",
"Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"echo PROBE_OK\" /dev/null)",
"Bash(echo \"probe_exit=$?\")",
"Bash(pkill -9 -f \"ssh.*10.1.1.5\")",
"Bash(pkill -9 -f \"expect /tmp/sshstdin\")",
"Bash(pkill -9 -f sshprobe)",
"Bash(pkill -9 -f \"b5wgdco4c\")",
"Bash(ps -p 99173 -o pid,command)",
"Bash(kill -9 99173)",
"Bash(chmod +x /tmp/run-deploy.sh)",
"Bash(bash /tmp/run-deploy.sh)",
"Bash(echo \"launcher exit=$?\")",
"Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"REMOTE_PASS='Waters2010!' sh -s\" /tmp/verify.sh)",
"Bash(curl -sS -m 10 -o /dev/null -w \"healthz -> HTTP %{http_code}\\\\n\" https://check.watersmedia.org/healthz)",
"Bash(curl -sS -m 10 https://check.watersmedia.org/)",
"Bash(echo \"hide-lists toggle \\(D4\\): $\\(curl -sS -m 10 https://check.watersmedia.org/)",
"Bash(rm -f checklist-deploy.tgz /tmp/deploy3.log /tmp/run-deploy.sh /tmp/sshhard.exp /tmp/verify.sh)",
"Bash(rm -f /tmp/sshstdin.exp)",
"Bash(sed -n '/function executiveSession/,/^}/p' server.js)",
"Bash(sed -n '/function findExecutive/,/^}/p' server.js)",
"Bash(curl -sS -m 10 -o /dev/null -w '%{http_code}' https://check.watersmedia.org/healthz)",
"Bash(echo \"healthz: $\\(curl -sS -m 10 -o /dev/null -w '%{http_code}' https://check.watersmedia.org/healthz\\)\")",
"Bash(curl -sS -m 10 https://check.watersmedia.org/app.js)",
"Bash(echo \"exec rename button \\(data-exec-action=rename\\): $\\(curl -sS -m 10 https://check.watersmedia.org/app.js)",
"Bash(echo \"team-login search box: $\\(curl -sS -m 10 https://check.watersmedia.org/)",
"Bash(rm -rf /tmp/cl-preview-data)",
"Bash(rm -f /Users/Studio/Documents/BandLineupDisplay/.claude/launch.json)",
"Bash(rmdir /Users/Studio/Documents/BandLineupDisplay/.claude)",
"Bash(node -c server/server.js)",
"Bash(node -c server/public/app.js)",
"Bash(echo \"exec scope badge \\(showScope in app.js\\): $\\(curl -sS -m 10 https://check.watersmedia.org/app.js)"
]
}
}
+38
View File
@@ -0,0 +1,38 @@
# Band Lineup
Stage lineup photos with mic/pack labels, for the backstage screen. Electron, packaged for Windows.
It also carries the ProPresenter **Now / Next** feature, which exists in two forms that share
one engine:
```
BandLineupDisplay/
├── main.js, preload.js, index.html, renderer.js, styles.css the Band Lineup app
├── propresenter-core/ the shared ProPresenter 7/20 client — no deps, no Electron
└── propresenter-app/ the standalone Now/Next display + the page the band app serves
```
| Want | Do this |
| --- | --- |
| Run the backstage app | `npm start` |
| Show Now/Next on screens around the building | In the app: **People** panel → **Studio Displays** → **Turn On**, then bookmark the address it gives you |
| Host the display without the Band Lineup app | `cd propresenter-app && node server.js` |
| Build the Windows installer | `npm run dist` |
`propresenter-core` is the single home for every ProPresenter quirk — runtime route discovery on
PP20, arrangement-expanded slide totals, uuid-then-name matching for the live item. Fix a quirk
there and both programs get it.
Details on the display, the bookmark that survives DHCP, and the Windows firewall gotcha:
[propresenter-app/README.md](propresenter-app/README.md).
## Testing without ProPresenter
`tmp/pp-mock.js` is a mock of the ProPresenter network API that advances slides on a timer,
including the tricky case where an arrangement repeats a section (43 cues over 41 master slides):
```bash
node tmp/pp-mock.js 1599
```
Then point either program at `127.0.0.1` port `1599`.
+112
View File
@@ -0,0 +1,112 @@
; Make upgrades self-healing:
; 1. Kill any running Band Lineup process tree.
; 2. Delete the previous install folder directly.
; 3. Remove stale registry keys so electron-builder does not invoke the old uninstaller.
!macro forceCloseBandLineup
DetailPrint "Force-closing ${PRODUCT_NAME}..."
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"`
Pop $R9
Sleep 500
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -WindowStyle Hidden -Command "Stop-Process -Name '${PRODUCT_NAME}' -Force -ErrorAction SilentlyContinue"`
Pop $R9
Sleep 500
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"`
Pop $R9
Sleep 1000
!macroend
!macro removePreviousInstall ROOT_KEY
ReadRegStr $R8 ${ROOT_KEY} "${INSTALL_REGISTRY_KEY}" "InstallLocation"
${if} $R8 != ""
DetailPrint "Removing previous installation from $R8"
!insertmacro forceCloseBandLineup
RMDir /r "$R8"
nsExec::Exec `"$SYSDIR\cmd.exe" /c rmdir /S /Q "$R8"`
Pop $R9
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -WindowStyle Hidden -Command "if (Test-Path -LiteralPath '$R8') { Remove-Item -LiteralPath '$R8' -Force -Recurse -ErrorAction SilentlyContinue }"`
Pop $R9
${endif}
DeleteRegKey ${ROOT_KEY} "${UNINSTALL_REGISTRY_KEY}"
!ifdef UNINSTALL_REGISTRY_KEY_2
DeleteRegKey ${ROOT_KEY} "${UNINSTALL_REGISTRY_KEY_2}"
!endif
DeleteRegKey ${ROOT_KEY} "${INSTALL_REGISTRY_KEY}"
!macroend
!macro customCheckAppRunning
!insertmacro forceCloseBandLineup
!macroend
!macro preInit
!insertmacro forceCloseBandLineup
!macroend
!macro customInit
!insertmacro forceCloseBandLineup
!insertmacro removePreviousInstall HKCU
!insertmacro removePreviousInstall HKLM
Sleep 1000
!macroend
!macro customUnInstallCheck
DetailPrint "Skipping old uninstaller result and continuing with clean install."
ClearErrors
StrCpy $R0 0
!macroend
!macro customUnInstallCheckCurrentUser
DetailPrint "Skipping old per-user uninstaller result and continuing with clean install."
ClearErrors
StrCpy $R0 0
!macroend
; ---------------------------------------------------------------------------
; Studio displays: allow the app through Windows Defender Firewall.
;
; Why this belongs in the installer: the firewall prompt fires the moment the app
; starts listening, and Microsoft documents that if the user is NOT a local admin,
; block rules are created no matter which button they press — and a block rule is
; sticky and silent afterwards. The backstage machine then serves localhost perfectly
; while every studio screen times out. The installer is the one place we reliably
; have elevation, so create the allow rules here.
;
; Rules are program-scoped (not port-scoped) so they survive the operator changing
; the display port in the app. UDP covers mDNS on 5353, which publishes the
; nownext.local name that makes bookmarks survive a DHCP change.
;
; NOTE: this needs an elevated (per-machine) install. With oneClick:false and
; perMachine unset, the user may choose a per-user install, where netsh will fail —
; harmlessly, thanks to the swallowed exit codes below. If studio screens can't
; connect, the in-app "Studio Displays" status says so and names the fix.
; ---------------------------------------------------------------------------
!macro addBandLineupFirewallRules
DetailPrint "Allowing ${PRODUCT_NAME} through Windows Firewall..."
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (TCP)"`
Pop $R9
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (UDP)"`
Pop $R9
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall add rule name="Band Lineup Display (TCP)" dir=in action=allow program="$INSTDIR\${APP_EXECUTABLE_FILENAME}" enable=yes profile=domain,private protocol=TCP`
Pop $R9
; UDP is port-locked deliberately: mDNS is always 5353 (a hard-coded constant in mdns.js),
; so unlike the TCP rule this can never go stale, and a program-wide UDP allow would open
; every UDP port the app ever binds.
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall add rule name="Band Lineup Display (UDP)" dir=in action=allow program="$INSTDIR\${APP_EXECUTABLE_FILENAME}" enable=yes profile=domain,private protocol=UDP localport=5353`
Pop $R9
!macroend
!macro removeBandLineupFirewallRules
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (TCP)"`
Pop $R9
nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (UDP)"`
Pop $R9
!macroend
!macro customInstall
!insertmacro addBandLineupFirewallRules
!macroend
!macro customUnInstall
!insertmacro removeBandLineupFirewallRules
!macroend
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' file: blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Band Lineup</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="top-edge-hit" id="top-edge-hit" aria-hidden="true"></div>
<header class="toolbar" id="toolbar">
<button type="button" class="btn primary" id="btn-add">Add photos</button>
<div class="toolbar-spacer"></div>
<div class="layout-controls">
<label class="layout-field">
<span>Cols</span>
<input type="number" id="layout-cols" min="1" step="1" value="7" />
</label>
<label class="layout-field">
<span>Rows</span>
<input type="number" id="layout-rows" min="1" step="1" value="2" />
</label>
<button type="button" class="btn" id="btn-apply-layout">Apply</button>
</div>
<button type="button" class="btn" id="btn-toggle-parallax">Parallax On</button>
<button type="button" class="btn" id="btn-fullscreen">Fullscreen</button>
<span class="app-version" id="app-version" title="Installed version"></span>
</header>
<div class="app-main">
<main class="stage" id="drop-zone">
<div class="row" id="row" aria-label="Lineup"></div>
<p class="empty" id="empty">Drag people from the right into any stage slot.</p>
</main>
<aside class="library-panel" id="library-panel" aria-label="Setup panel">
<nav class="library-tabs" id="library-tabs" role="tablist" aria-label="Panel sections">
<button type="button" class="lib-tab is-active" id="tab-pictures" data-tab="pictures" role="tab" aria-selected="true">Pictures</button>
<button type="button" class="lib-tab" id="tab-pco" data-tab="pco" role="tab" aria-selected="false">PCO</button>
<button type="button" class="lib-tab" id="tab-propresenter" data-tab="propresenter" role="tab" aria-selected="false">ProPresenter</button>
</nav>
<div class="library-pane is-active" data-pane="pictures" role="tabpanel" aria-labelledby="tab-pictures">
<div class="library-scroll" id="library-list"></div>
<div class="pane-foot">
<span class="slot-size-readout" id="slot-size-readout"></span>
</div>
</div>
<div class="library-pane" data-pane="pco" role="tabpanel" aria-labelledby="tab-pco">
<div class="pane-scroll">
<div class="pane-head">
<span class="pane-title">Planning Center</span>
<button type="button" class="btn btn-small" id="btn-pco-connect">Connect PCO</button>
</div>
<div class="pco-controls">
<select id="pco-service-type" class="pco-select" aria-label="Planning Center service type">
<option value="">Choose service type</option>
</select>
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
</div>
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
<div class="pco-position-builder">
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
<option value="">Choose position</option>
</select>
<button type="button" class="btn btn-small" id="btn-pco-add-position">Add Position</button>
</div>
<div class="pco-position-list" id="pco-position-list" aria-label="Selected Planning Center positions"></div>
</div>
</div>
<div class="library-pane" data-pane="propresenter" role="tabpanel" aria-labelledby="tab-propresenter">
<div class="pane-scroll">
<div class="pane-head">
<span class="pane-title">ProPresenter</span>
<div class="pp-controls-buttons">
<button type="button" class="btn btn-small" id="btn-pp-connect">Connect</button>
<button type="button" class="btn btn-small" id="btn-pp-disconnect">Disconnect</button>
</div>
</div>
<div class="pp-status" id="pp-status">ProPresenter is not connected.</div>
<select id="pp-playlist-select" class="pco-select pp-playlist-select" aria-label="ProPresenter playlist to display">
<option value="">Choose playlist</option>
</select>
<div class="pane-head pane-head-spaced">
<span class="pane-title">Studio Displays</span>
<button type="button" class="btn btn-small" id="btn-display-toggle">Turn On</button>
</div>
<div class="pp-status" id="display-status">Off &mdash; screens around the building can&rsquo;t see this yet.</div>
<div class="display-urls" id="display-urls"></div>
<div class="display-port-row">
<label class="display-port-field">
<span>Port</span>
<input type="text" id="display-port" inputmode="numeric" autocomplete="off" spellcheck="false" />
</label>
</div>
</div>
</div>
</aside>
<aside class="pp-column" id="pp-column" aria-label="ProPresenter playlist position">
<div class="pp-playlist-name" id="pp-playlist-name"></div>
<div class="pp-now" id="pp-now"></div>
<div class="pp-next-wrap">
<div class="pp-section-label" id="pp-next-label">Next</div>
<div class="pp-next-list" id="pp-next-list"></div>
</div>
</aside>
</div>
<div id="label-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="label-modal-title">
<div class="modal-backdrop" id="label-modal-backdrop"></div>
<div class="modal-card">
<h2 id="label-modal-title" class="modal-title">Edit label</h2>
<input type="text" class="label-input" id="label-input" autocomplete="off" spellcheck="false" />
<div class="vkeyboard" id="vkeyboard" aria-label="On-screen keyboard"></div>
<div class="modal-actions">
<button type="button" class="btn" id="label-cancel">Cancel</button>
<button type="button" class="btn primary" id="label-done">Done</button>
</div>
</div>
</div>
<div id="number-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="number-modal-title">
<div class="modal-backdrop" id="number-modal-backdrop"></div>
<div class="modal-card modal-card-small">
<h2 id="number-modal-title" class="modal-title">Set value</h2>
<input type="text" class="label-input number-input" id="number-input" inputmode="numeric" autocomplete="off" readonly />
<div class="vkeyboard" id="number-keyboard" aria-label="On-screen number keyboard"></div>
<div class="modal-actions">
<button type="button" class="btn" id="number-cancel">Cancel</button>
<button type="button" class="btn primary" id="number-done">Done</button>
</div>
</div>
</div>
<div id="pco-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="pco-modal-title">
<div class="modal-backdrop" id="pco-modal-backdrop"></div>
<div class="modal-card modal-card-small">
<h2 id="pco-modal-title" class="modal-title">Connect Planning Center</h2>
<p class="modal-copy">Paste the Client ID and Secret from your Planning Center personal access token.</p>
<label class="modal-field">
<span>Client ID</span>
<input type="text" class="label-input modal-input" id="pco-client-id" autocomplete="off" spellcheck="false" />
</label>
<label class="modal-field">
<span>Secret</span>
<input type="password" class="label-input modal-input" id="pco-secret" autocomplete="off" spellcheck="false" />
</label>
<div class="modal-actions">
<button type="button" class="btn" id="pco-cancel">Cancel</button>
<button type="button" class="btn primary" id="pco-save">Connect</button>
</div>
</div>
</div>
<div id="pp-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="pp-modal-title">
<div class="modal-backdrop" id="pp-modal-backdrop"></div>
<div class="modal-card modal-card-small">
<h2 id="pp-modal-title" class="modal-title">Connect ProPresenter</h2>
<p class="modal-copy">Enter the IP address and network port of the computer running ProPresenter. Turn on the API in ProPresenter ▸ Preferences ▸ Network.</p>
<label class="modal-field">
<span>Host / IP address</span>
<input type="text" class="label-input modal-input" id="pp-host" autocomplete="off" spellcheck="false" placeholder="192.168.1.50" />
</label>
<label class="modal-field">
<span>Port</span>
<input type="text" class="label-input modal-input" id="pp-port" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="1025" />
</label>
<label class="modal-field">
<span>Password (only if set in ProPresenter)</span>
<input type="password" class="label-input modal-input" id="pp-password" autocomplete="off" spellcheck="false" />
</label>
<div class="modal-actions">
<button type="button" class="btn" id="pp-cancel">Cancel</button>
<button type="button" class="btn primary" id="pp-save">Connect</button>
</div>
</div>
</div>
<div id="drag-ghost" class="drag-ghost hidden" aria-hidden="true"></div>
<script src="renderer.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
{
"name": "band-lineup-display",
"productName": "Band Lineup",
"version": "1.3.6",
"buildNumber": "92",
"description": "Stage lineup photos with mic/pack labels",
"main": "main.js",
"scripts": {
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder --win --x64",
"postdist": "node scripts/archive-builds.js",
"archive": "node scripts/archive-builds.js"
},
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^33.2.0",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "com.band.lineup.display",
"productName": "Band Lineup",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"index.html",
"renderer.js",
"styles.css",
"propresenter-core/**/*",
"propresenter-app/server.js",
"propresenter-app/config.js",
"propresenter-app/mdns.js",
"propresenter-app/public/**/*"
],
"win": {
"target": [
"nsis",
"portable"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"include": "build/installer.nsh",
"differentialPackage": false,
"useZip": true
}
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"sharp": "^0.34.5"
},
"optionalDependencies": {
"@img/sharp-libvips-win32-x64": "^1.2.4",
"@img/sharp-win32-x64": "^0.34.5"
}
}
+38
View File
@@ -0,0 +1,38 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('lineup', {
addImagesDialog: () => ipcRenderer.invoke('add-images-dialog'),
addImagesPaths: (paths) => ipcRenderer.invoke('add-images-paths', paths),
loadState: () => ipcRenderer.invoke('load-state'),
saveState: (payload) => ipcRenderer.invoke('save-state', payload),
deleteLibraryImage: (fileName) => ipcRenderer.invoke('delete-library-image', fileName),
imageUrl: (fileName) => ipcRenderer.invoke('image-url', fileName),
parallaxAssets: (fileName) => ipcRenderer.invoke('parallax-assets', fileName),
pcoStatus: () => ipcRenderer.invoke('pco-status'),
pcoConnect: (payload) => ipcRenderer.invoke('pco-connect', payload),
pcoDisconnect: () => ipcRenderer.invoke('pco-disconnect'),
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
onMenuAddPhotos: (cb) => {
ipcRenderer.on('menu-add-photos', () => cb());
},
appVersion: () => ipcRenderer.invoke('app-version'),
display: {
status: () => ipcRenderer.invoke('display-status'),
setEnabled: (enabled) => ipcRenderer.invoke('display-set-enabled', enabled),
setPort: (port) => ipcRenderer.invoke('display-set-port', port),
copyUrl: (url) => ipcRenderer.invoke('display-copy-url', url),
},
proPresenter: {
status: () => ipcRenderer.invoke('pp-status'),
connect: (payload) => ipcRenderer.invoke('pp-connect', payload),
disconnect: () => ipcRenderer.invoke('pp-disconnect'),
start: () => ipcRenderer.invoke('pp-start'),
listPlaylists: () => ipcRenderer.invoke('pp-playlists'),
selectPlaylist: (payload) => ipcRenderer.invoke('pp-select-playlist', payload),
onData: (cb) => {
ipcRenderer.on('pp-data', (_event, data) => cb(data));
},
},
});
@@ -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));
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
{
"name": "propresenter-core",
"version": "1.0.0",
"private": true,
"description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)",
"main": "index.js",
"license": "MIT"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
'use strict';
/**
* Keep old installers instead of overwriting them.
*
* Runs automatically after `npm run dist` (see the "postdist" script). The freshly built
* version stays at the top level of dist/ so there is never a question about which file to
* install; every older version is moved into dist/archive/ and the archive is pruned to the
* most recent KEEP_VERSIONS so the folder cannot grow without bound.
*
* Zero dependencies. Set DIST_DIR to point it somewhere else (used by the tests).
*/
const fs = require('fs');
const path = require('path');
/** How many previous versions to keep. Each version is ~460 MB (installer + portable). */
const KEEP_VERSIONS = 5;
const ROOT = path.join(__dirname, '..');
const DIST = process.env.DIST_DIR || path.join(ROOT, 'dist');
const ARCHIVE = path.join(DIST, 'archive');
/** Pull "1.2.1" out of "Band Lineup Setup 1.2.1.exe". */
function versionOf(fileName) {
const match = fileName.match(/(\d+\.\d+\.\d+)/);
return match ? match[1] : '';
}
function compareVersions(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i += 1) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
}
function currentVersion() {
if (process.env.CURRENT_VERSION) return process.env.CURRENT_VERSION;
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
}
/** Installer-ish artifacts worth keeping. Build scratch (win-unpacked, logs) is left alone. */
function isArtifact(fileName) {
return /\.(exe|blockmap|dmg|zip|appx|msi)$/i.test(fileName);
}
function humanSize(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
}
function run() {
if (!fs.existsSync(DIST)) {
console.log('[archive-builds] no dist/ yet — nothing to do');
return;
}
const version = currentVersion();
fs.mkdirSync(ARCHIVE, { recursive: true });
// 1. Move anything that isn't the current version out of the way.
let moved = 0;
for (const name of fs.readdirSync(DIST)) {
const from = path.join(DIST, name);
if (!fs.statSync(from).isFile() || !isArtifact(name)) continue;
const fileVersion = versionOf(name);
if (!fileVersion || fileVersion === version) continue;
const to = path.join(ARCHIVE, name);
fs.renameSync(from, to);
moved += 1;
console.log(`[archive-builds] archived ${name}`);
}
// 2. Prune the archive to the newest KEEP_VERSIONS, by version number.
const byVersion = new Map();
for (const name of fs.readdirSync(ARCHIVE)) {
const full = path.join(ARCHIVE, name);
if (!fs.statSync(full).isFile() || !isArtifact(name)) continue;
const fileVersion = versionOf(name);
if (!fileVersion) continue;
if (!byVersion.has(fileVersion)) byVersion.set(fileVersion, []);
byVersion.get(fileVersion).push(full);
}
const versions = [...byVersion.keys()].sort(compareVersions).reverse();
const drop = versions.slice(KEEP_VERSIONS);
for (const old of drop) {
for (const file of byVersion.get(old)) {
fs.unlinkSync(file);
}
console.log(`[archive-builds] pruned ${old} (keeping the newest ${KEEP_VERSIONS})`);
}
const kept = versions.slice(0, KEEP_VERSIONS);
let bytes = 0;
for (const v of kept) {
for (const file of byVersion.get(v)) bytes += fs.statSync(file).size;
}
console.log(
`[archive-builds] current: ${version} in dist/ | archived: ${kept.join(', ') || 'none yet'}`
+ (bytes ? ` (${humanSize(bytes)})` : ''),
);
if (!moved && !kept.length) console.log('[archive-builds] first run — nothing to archive yet');
}
run();
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
/**
* Mock ProPresenter 7 network API for verifying the Band Lineup integration
* without a live ProPresenter. Serves the documented endpoint shapes and a
* chunked /v1/status/updates stream that advances the active slide on a timer.
*
* node tmp/pp-mock.js [port] (default 1599)
*
* Every incoming request is logged so we can confirm what the app fetches.
*/
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
const STEP_MS = Number.parseInt(process.env.PP_STEP_MS || '1500', 10);
// Playlist: header + presentations + a media item + a hidden item.
// `slides` = unique master slides returned by /v1/presentation/{uuid} (what ppCountSlides sees).
// `arr` = cues the SELECTED arrangement actually plays (what slide_index + thumbnails respect).
// When arr > slides the arrangement repeats a section (e.g. a chorus) — this is the 43/41 case.
const ITEMS = [
{ uuid: 'I0', name: 'Pre-Service Loop', type: 'header', is_hidden: false, is_pco: false },
{ uuid: 'I1', name: 'Welcome', type: 'presentation', is_hidden: false, is_pco: false, slides: 3 },
{ uuid: 'I2', name: 'This Is The Day - [ Full ]', type: 'presentation', is_hidden: false, is_pco: false, slides: 41, arr: 43 },
{ uuid: 'I3', name: 'Sermon: Living Hope', type: 'presentation', is_hidden: false, is_pco: true, slides: 5 },
{ uuid: 'I4', name: 'Bumper Video', type: 'media', is_hidden: false, is_pco: false },
{ uuid: 'I5', name: 'Closing Song', type: 'presentation', is_hidden: false, is_pco: false, slides: 8 },
{ uuid: 'IH', name: 'Hidden Cue', type: 'presentation', is_hidden: true, is_pco: false, slides: 4 },
{ uuid: 'I6', name: 'Benediction', type: 'presentation', is_hidden: false, is_pco: false, slides: 2 },
];
/** Arrangement-expanded cue count for an item (defaults to its master slide count). */
function arrLen(item) {
return item && item.arr ? item.arr : (item && item.slides ? item.slides : 1);
}
const PLAYLIST = { uuid: 'PL1', name: 'Sunday AM — June 1', index: 0 };
// Only presentation items can be "active". Start on Amazing Grace.
const PRESENTATIONS = ITEMS.filter((it) => it.type === 'presentation');
let activePresIdx = PRESENTATIONS.findIndex((it) => it.uuid === 'I2');
let slide = 0;
function activeItem() {
return PRESENTATIONS[activePresIdx];
}
function playlistIndexOfActive() {
return ITEMS.findIndex((it) => it.uuid === activeItem().uuid);
}
function slideIndexPayload() {
// PP20 shape: top-level `presentation_index`.
const item = activeItem();
return {
presentation_index: {
index: slide,
presentation_id: { uuid: item.uuid, name: item.name, index: playlistIndexOfActive() },
},
};
}
function presentationPayload(uuid) {
// PP20 shape: details wrapped under `presentation`.
const item = ITEMS.find((it) => it.uuid === uuid);
const n = item && item.slides ? item.slides : 0;
const slides = Array.from({ length: n }, (_s, i) => ({
enabled: true,
notes: '',
text: `${item ? item.name : ''} — slide ${i + 1}`,
image: '',
label: '',
color: { red: 0, green: 0, blue: 0, alpha: 0 },
}));
return {
presentation: {
id: { uuid, name: item ? item.name : '', index: 0 },
name: item ? item.name : '',
index: 0,
groups: [{ name: 'Group', color: {}, slides }],
has_timeline: false,
},
};
}
function playlistItemsPayload() {
// ProPresenter 20 style: items carry id{uuid,name,index} + field_type.
return {
id: PLAYLIST,
items: ITEMS.map((it, index) => ({
id: { uuid: it.uuid, name: it.name, index },
field_type: it.type,
is_hidden: it.is_hidden,
is_pco: it.is_pco,
})),
};
}
function currentItemId() {
const it = activeItem();
return { uuid: it.uuid, name: it.name, index: ITEMS.findIndex((x) => x.uuid === it.uuid) };
}
// ProPresenter 20: what's live and what's focused (auto-follow sources).
function activePlaylistPayload() {
return { presentation: { playlist: PLAYLIST, item: currentItemId() }, announcements: { playlist: null, item: null } };
}
function focusedPlaylistPayload() {
return { playlist: PLAYLIST, item: currentItemId() };
}
// ProPresenter 20 /v1/playlists returns a tree of groups → playlists.
function playlistsTreePayload() {
return [
{
id: { uuid: 'GRP-SERVICES', name: 'Services', index: 0 },
field_type: 'group',
children: [
{ id: { uuid: PLAYLIST.uuid, name: PLAYLIST.name, index: 0 }, field_type: 'playlist', children: [] },
{ id: { uuid: 'PL-OTHER', name: 'Speaker Pics', index: 1 }, field_type: 'playlist', children: [] },
],
},
];
}
const subscribers = new Set();
let lastPollAt = 0; // updated when a client polls slide_index, so we also advance under polling clients
function broadcastSlideIndex() {
const line = JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n';
for (const res of subscribers) res.write(line);
}
// Advance: walk slides of the active presentation, then jump to the next presentation item.
// Only advance once something is subscribed, so REST snapshots before subscribing are deterministic.
setInterval(() => {
if (subscribers.size === 0 && Date.now() - lastPollAt > 3000) return;
const item = activeItem();
slide += 1;
if (slide >= arrLen(item)) {
slide = 0;
activePresIdx = (activePresIdx + 1) % PRESENTATIONS.length;
console.log(`[mock] → now presenting "${activeItem().name}"`);
}
broadcastSlideIndex();
}, STEP_MS);
function sendJson(res, obj) {
const body = JSON.stringify(obj);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(body);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
console.log(`[mock] ${req.method} ${p}`);
if (p === '/version') return sendJson(res, { name: 'NA-ProP (mock)', platform: 'mac', host_description: 'ProPresenter 20.0.1', api_version: 'v1' });
if (p === '/v1/playlists') return sendJson(res, playlistsTreePayload());
if (p === '/v1/playlist/active') return sendJson(res, activePlaylistPayload());
if (p === '/v1/playlist/focused') return sendJson(res, focusedPlaylistPayload());
// Simulate PP20: the items list lives at a renamed route (uuid/dot/colon all 404).
// Services group index 0, First Tuesday index 0 → dash-joined path "0-0".
if (p === '/v1/playlist/0-0') return sendJson(res, playlistItemsPayload());
if (p === '/v1/presentation/slide_index') { lastPollAt = Date.now(); return sendJson(res, slideIndexPayload()); }
// Thumbnail probe: index "respects the selected arrangement" → valid for 0..arrLen-1, else 404.
const thumb = p.match(/^\/v1\/presentation\/([^/]+)\/thumbnail\/(\d+)$/);
if (thumb) {
const item = ITEMS.find((it) => it.uuid === decodeURIComponent(thumb[1]));
const index = Number.parseInt(thumb[2], 10);
if (item && index >= 0 && index < arrLen(item)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
return res.end(Buffer.from([0xff, 0xd8, 0xff, 0xd9])); // tiny stub JPEG
}
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'index out of range' }));
}
if (p === '/v1/presentation/current' || p === '/v1/presentation/active') return sendJson(res, presentationPayload(activeItem().uuid));
if (p.startsWith('/v1/presentation/')) {
const uuid = decodeURIComponent(p.split('/').pop());
return sendJson(res, presentationPayload(uuid));
}
if (p === '/v1/status/updates' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
let urls = [];
try { urls = JSON.parse(body); } catch { /* ignore */ }
console.log(`[mock] status/updates subscribe: ${JSON.stringify(urls)}`);
res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
// initial snapshot for each subscribed url
if (urls.includes('playlist/current')) res.write(JSON.stringify({ url: 'playlist/current', data: PLAYLIST }) + '\n');
if (urls.includes('presentation/slide_index')) res.write(JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n');
subscribers.add(res);
res.on('close', () => subscribers.delete(res));
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[mock] ProPresenter mock API on http://127.0.0.1:${PORT} (slide step ${STEP_MS}ms)`);
});
@@ -0,0 +1,47 @@
// Verifies the slide-count + "remaining" logic (mirrors main.js) against tmp/pp-mock.js.
// The total comes from probing the thumbnail boundary (arrangement-expanded cue count),
// which lives in the SAME index space as slide_index — so "Slide X / Y" can never have X > Y,
// even when the selected arrangement repeats a section (the 43/41 bug this fixes).
const HOST = '127.0.0.1';
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
async function get(p) {
const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`${p} -> ${r.status}`);
return r.json();
}
function countSlides(d) {
const p = d?.presentation || d;
const groups = Array.isArray(p?.groups) ? p.groups : [];
let c = 0;
for (const g of groups) c += Array.isArray(g?.slides) ? g.slides.length : 0;
return c;
}
// Mirror of ppProbeArrangementLength: smallest thumbnail index that 404s = cue count.
async function status(p) {
const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'image/jpeg' } });
return r.status;
}
async function probeArrangementLength(uuid) {
const exists = async (i) => (await status(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${i}`)) === 200;
if (!(await exists(0))) return 0;
let lo = 0; let hi = 1;
while (await exists(hi)) { lo = hi; hi *= 2; if (hi > 4000) return null; }
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (await exists(mid)) lo = mid; else hi = mid; }
return lo + 1;
}
(async () => {
const si = await get('/v1/presentation/slide_index');
const node = si?.presentation_index || si?.presentation;
const current = node && Number.isInteger(node.index) ? node.index + 1 : 0;
const uuid = node?.presentation_id?.uuid || '';
const det = await get(`/v1/presentation/${encodeURIComponent(uuid)}`);
const master = countSlides(det);
const total = await probeArrangementLength(uuid);
const remaining = Math.max(0, total - current);
console.log(`live presentation uuid: ${uuid}`);
console.log(`master slides (/presentation/{uuid}): ${master} arrangement cues (thumbnail probe): ${total}`);
console.log(`NOW card → "Slide ${current} / ${total} · ${remaining} left"`);
const ok = current > 0 && total > 0 && total >= current && remaining === total - current;
console.log(ok ? '\n✅ total matches the slide_index space (X never exceeds Y)' : '\n❌ FAILED');
process.exit(ok ? 0 : 1);
})().catch((e) => { console.error('error:', e.message); process.exit(2); });
+139
View File
@@ -0,0 +1,139 @@
/**
* Verifies the ProPresenter data pipeline used by main.js against tmp/pp-mock.js.
* Mirrors the main-process client logic (stream parsing, slide-count summing,
* active-item matching, NOW/NEXT assembly) and asserts the resulting view-model.
*
* node tmp/pp-verify.js [port]
*/
const HOST = '127.0.0.1';
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
let failures = 0;
function check(label, cond) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`);
if (!cond) failures += 1;
}
// ---- client logic mirrored from main.js ----
const slideCountCache = new Map();
let state = { reachable: false, playlistUuid: '', playlistName: '', items: [], activeUuid: '', activeName: '', activeIndex: -1, currentSlide: 0 };
function ppUrl(target) { return `http://${HOST}:${PORT}${target}`; }
async function ppRequestJson(target, init) {
const r = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, ...init });
if (!r.ok) throw new Error(`${r.status}`);
const t = await r.text();
return t ? JSON.parse(t) : null;
}
async function ppSlideCount(uuid) {
if (!uuid) return null;
if (slideCountCache.has(uuid)) return slideCountCache.get(uuid);
try {
const data = await ppRequestJson(`/v1/presentation/${encodeURIComponent(uuid)}`);
let count = 0;
for (const g of data?.groups || []) for (const s of g?.slides || []) if (s?.enabled !== false) count += 1;
slideCountCache.set(uuid, count);
return count;
} catch { return null; }
}
function recomputeActiveIndex() {
state.activeIndex = state.activeUuid ? state.items.findIndex((it) => it.uuid && it.uuid === state.activeUuid) : -1;
}
async function refreshCurrentPlaylist() {
const cur = await ppRequestJson('/v1/playlist/current');
state.playlistUuid = String(cur?.uuid || '');
state.playlistName = String(cur?.name || '');
if (!state.playlistUuid) { state.items = []; recomputeActiveIndex(); return; }
const data = await ppRequestJson(`/v1/playlist/${encodeURIComponent(state.playlistUuid)}`);
state.items = (data?.items || []).map((it) => ({
uuid: String(it?.id?.uuid || ''), name: String(it?.id?.name || ''), type: String(it?.type || ''),
isHidden: Boolean(it?.is_hidden), isPco: Boolean(it?.is_pco), slideCount: null,
}));
recomputeActiveIndex();
}
async function fillSlideCounts() {
for (const it of state.items) if (it.type === 'presentation' && it.uuid && it.slideCount === null) it.slideCount = await ppSlideCount(it.uuid);
}
function applySlideIndex(data) {
const pres = data?.presentation;
if (!pres || !pres.presentation_id) { state.activeUuid = ''; state.activeName = ''; state.currentSlide = 0; recomputeActiveIndex(); return; }
state.currentSlide = Number.isInteger(pres.index) ? pres.index + 1 : 0;
state.activeUuid = String(pres.presentation_id.uuid || '');
state.activeName = String(pres.presentation_id.name || '');
recomputeActiveIndex();
}
async function viewModel() {
let total = 0;
if (state.activeUuid) { const c = slideCountCache.get(state.activeUuid); total = Number.isInteger(c) ? c : (await ppSlideCount(state.activeUuid)) || 0; }
return { reachable: state.reachable, playlistName: state.playlistName, items: state.items, activeIndex: state.activeIndex, activePresentationName: state.activeName, currentSlide: state.currentSlide, currentSlideTotal: total };
}
function describe(vm) {
const now = vm.activeIndex >= 0 ? vm.items[vm.activeIndex].name : vm.activePresentationName;
const from = vm.activeIndex >= 0 ? vm.activeIndex + 1 : 0;
const next = vm.items.slice(from).filter((it) => !it.isHidden)
.map((it) => it.type === 'presentation' ? `${it.name} (${it.slideCount})` : `${it.name} [${it.type}]`);
return `NOW: ${now} — slide ${vm.currentSlide}/${vm.currentSlideTotal}\n NEXT: ${next.join(' · ')}`;
}
async function main() {
await refreshCurrentPlaylist();
applySlideIndex(await ppRequestJson('/v1/presentation/slide_index'));
await fillSlideCounts();
state.reachable = true;
const vm = await viewModel();
console.log('\n--- initial view-model ---\n' + describe(vm) + '\n');
const byName = (n) => vm.items.find((it) => it.name === n);
check('playlist name', vm.playlistName === 'Sunday AM — June 1');
check('item count = 8', vm.items.length === 8);
check('Welcome slides = 3', byName('Welcome').slideCount === 3);
check('Amazing Grace slides = 12', byName('Amazing Grace').slideCount === 12);
check('Sermon slides = 5', byName('Sermon: Living Hope').slideCount === 5);
check('Closing slides = 8', byName('Closing Song').slideCount === 8);
check('Benediction slides = 2', byName('Benediction').slideCount === 2);
check('media item has no count', byName('Bumper Video').slideCount === null);
check('header item has no count', byName('Pre-Service Loop').slideCount === null);
check('active is Amazing Grace', vm.activeIndex === vm.items.findIndex((it) => it.name === 'Amazing Grace'));
check('current slide >= 1', vm.currentSlide >= 1);
check('current total = 12', vm.currentSlideTotal === 12);
const from = vm.activeIndex + 1;
const nextNames = vm.items.slice(from).filter((it) => !it.isHidden).map((it) => it.name);
check('NEXT excludes hidden cue', !nextNames.includes('Hidden Cue'));
check('NEXT starts with Sermon', nextNames[0] === 'Sermon: Living Hope');
// Now exercise the live chunked stream for a few updates.
console.log('\n--- streaming live updates ---');
setTimeout(() => { console.error('TIMEOUT waiting for stream updates'); process.exit(3); }, 8000);
const res = await fetch(ppUrl('/v1/status/updates'), {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(['presentation/slide_index', 'playlist/current']),
});
check('stream opened (chunked)', res.ok && !!res.body);
let updates = 0;
const seenSlides = [];
let buffer = '';
const decoder = new TextDecoder();
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
if (String(msg.url).includes('presentation/slide_index')) {
applySlideIndex(msg.data);
seenSlides.push(state.currentSlide);
console.log(describe(await viewModel()).split('\n')[0]);
updates += 1;
}
}
if (updates >= 4) break;
}
check('received >= 4 live slide updates', updates >= 4);
check('slide value changed across updates', new Set(seenSlides).size >= 2);
console.log(`\n${failures === 0 ? '✅ ALL CHECKS PASSED' : `❌ ${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => { console.error('verify error:', e); process.exit(2); });
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "prodcom-listener",
"runtimeExecutable": "node",
"runtimeArgs": ["server.js"],
"port": 24481
}
]
}
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
node_modules/
+70
View File
@@ -0,0 +1,70 @@
# ProdCom Listener
A small web app that shows the live ProdCom transcript stream in any browser
on your network, and can be installed as a desktop web app on macOS and
Windows.
## How it works
`server.js` (zero dependencies, just Node.js) does two things:
1. Serves the web UI (`public/`).
2. Proxies everything under `/prodcom/*` to the ProdCom API, including the
Server-Sent Events transcript stream — so browsers on other machines never
have to talk to ProdCom directly (no CORS issues).
The page loads recent transcript history, then follows the live SSE stream.
In-progress speech updates in place; entries show the channel name in its
ProdCom color. Reconnects automatically if the stream drops.
## Run it
Requires [Node.js](https://nodejs.org) (any recent version).
```bash
node server.js
```
Or double-click **Start ProdCom Listener.command** (macOS) / **start.bat**
(Windows).
Then open the URL it prints — `http://localhost:24481` on this machine, or
the `http://<this-machine's-IP>:24481` address from any other device on the
network.
## Configure
Edit [config.json](config.json):
| Key | Meaning |
| --- | --- |
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
| `port` | Port this listener serves on (default `24481`) |
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
Environment variables `PRODCOM_URL`, `PORT`, and `PRODCOM_API_KEY` override
the file.
## Install as a desktop web app
Open the page in a browser first, then:
- **macOS — Safari**: File → **Add to Dock…**
- **macOS/Windows — Chrome**: click the install icon in the address bar, or
⋮ menu → Cast, save and share → **Install page as app…**
- **Windows — Edge**: ⋯ menu → Apps → **Install this site as an app**
Note: Chrome only offers the one-click PWA install on `http://localhost` (a
"secure context"). When opening from *another* machine over plain HTTP, use
Edge's "Install this site as an app" or Chrome's ⋮ → Cast, save and share →
"Create shortcut…" (check "Open as window") — same result: a dock/taskbar app
in its own window.
## UI
- **Status dot** — green = live stream connected, red = reconnecting.
- **Channel dropdown** — filter to a single channel.
- **A− / A+** — text size (remembered per browser).
- **Clear** — clears this view only; never touches ProdCom's transcript.
- Scroll up to pause auto-scroll; a "↓ New messages" button appears to jump
back to live.
+4
View File
@@ -0,0 +1,4 @@
#!/bin/zsh
# Double-click to start ProdCom Listener (macOS)
cd "$(dirname "$0")"
exec node server.js
+5
View File
@@ -0,0 +1,5 @@
{
"prodcomUrl": "http://10.3.11.152:24480",
"port": 24481,
"apiKey": ""
}
+474
View File
@@ -0,0 +1,474 @@
'use strict';
/* ProdCom Listener client — live transcript stream via SSE (proxied through
this app's server at /prodcom/* to avoid CORS). */
const streamEl = document.getElementById('stream');
const entriesEl = document.getElementById('entries');
const emptyEl = document.getElementById('empty-state');
const dotEl = document.getElementById('status-dot');
const channelBtn = document.getElementById('channel-btn');
const channelMenu = document.getElementById('channel-menu');
const jumpBtn = document.getElementById('jump-latest');
const channels = new Map(); // channelId -> {name, color}
const groups = new Map(); // groupId -> {name, channelIds}
const entryEls = new Map(); // entryId -> element
let pinnedToBottom = true;
/* ── status ─────────────────────────────────────────────────────────── */
function setStatus(state, text) {
dotEl.className = 'dot ' + state;
dotEl.title = text;
}
/* ── menubar ────────────────────────────────────────────────────────── */
document.getElementById('menu-handle').addEventListener('click', () => {
document.body.classList.toggle('menu-open');
});
/* ── font size ──────────────────────────────────────────────────────── */
let fontSize = Number(localStorage.getItem('entrySize')) || 17;
applyFontSize();
function applyFontSize() {
fontSize = Math.min(48, Math.max(11, fontSize));
document.documentElement.style.setProperty('--entry-size', fontSize + 'px');
localStorage.setItem('entrySize', fontSize);
}
document.getElementById('font-inc').addEventListener('click', () => { fontSize += 2; applyFontSize(); });
document.getElementById('font-dec').addEventListener('click', () => { fontSize -= 2; applyFontSize(); });
/* ── timestamp reveal ───────────────────────────────────────────────── */
/* Timestamps live in a column off-viewport to the left. A horizontal
swipe/scroll drags them into view and they snap back on release
(like message timestamps on iOS/macOS). The clock button pins them. */
const timeToggle = document.getElementById('time-toggle');
let timesPinned = localStorage.getItem('timesPinned') === '1';
applyTimesPinned();
timeToggle.addEventListener('click', () => {
timesPinned = !timesPinned;
localStorage.setItem('timesPinned', timesPinned ? '1' : '0');
applyTimesPinned();
});
function applyTimesPinned() {
document.body.classList.toggle('show-times', timesPinned);
timeToggle.classList.toggle('active', timesPinned);
timeToggle.setAttribute('aria-pressed', String(timesPinned));
entriesEl.style.transform = '';
}
function timeSlotPx() {
// the wrapper's negative margin IS the slot width (see --time-slot in css)
return -parseFloat(getComputedStyle(entriesEl).marginLeft) || 0;
}
function currentTranslateX() {
const t = getComputedStyle(entriesEl).transform;
if (t && t !== 'none') return new DOMMatrixReadOnly(t).m41;
return 0;
}
let reveal = 0;
let wheelTimer = null;
function dragTo(px) {
entriesEl.classList.add('dragging');
entriesEl.style.transform = 'translateX(' + px + 'px)';
}
function snapBack() {
reveal = 0;
entriesEl.classList.remove('dragging');
entriesEl.style.transform = ''; // animate back to the CSS resting position
}
streamEl.addEventListener('wheel', (e) => {
if (timesPinned) return;
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; // vertical scroll
if (!entriesEl.classList.contains('dragging')) reveal = currentTranslateX();
const next = Math.max(0, Math.min(timeSlotPx(), reveal - e.deltaX));
if (next === reveal && reveal === 0) return;
reveal = next;
e.preventDefault();
dragTo(reveal);
clearTimeout(wheelTimer);
wheelTimer = setTimeout(snapBack, 160);
}, { passive: false });
let touchStartX = 0;
let touchStartY = 0;
let touchMode = null; // 'h' | 'v'
streamEl.addEventListener('touchstart', (e) => {
const t = e.touches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
touchMode = null;
}, { passive: true });
streamEl.addEventListener('touchmove', (e) => {
if (timesPinned) return;
const t = e.touches[0];
const dx = t.clientX - touchStartX;
const dy = t.clientY - touchStartY;
if (!touchMode) touchMode = Math.abs(dx) > Math.abs(dy) + 4 ? 'h' : 'v';
if (touchMode !== 'h') return;
e.preventDefault();
reveal = Math.max(0, Math.min(timeSlotPx(), dx));
dragTo(reveal);
}, { passive: false });
streamEl.addEventListener('touchend', () => {
if (touchMode === 'h') snapBack();
});
/* ── clear (local view only) ────────────────────────────────────────── */
document.getElementById('clear-btn').addEventListener('click', () => {
entryEls.forEach((el) => el.remove());
entryEls.clear();
emptyEl.hidden = false;
});
/* ── channel visibility toggles ─────────────────────────────────────── */
/* Every channel is visible unless its id is in this set. Unknown channels
(entries not matching the channel list) always show. */
let hiddenChannels = new Set();
try { hiddenChannels = new Set(JSON.parse(localStorage.getItem('hiddenChannels') || '[]')); } catch {}
channelBtn.addEventListener('click', () => {
channelMenu.hidden = !channelMenu.hidden;
});
document.addEventListener('click', (e) => {
if (!channelMenu.hidden && !e.target.closest('#channel-dd')) channelMenu.hidden = true;
});
function applyFilter(el) {
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
}
function toggleChannel(id) {
if (hiddenChannels.has(id)) hiddenChannels.delete(id);
else hiddenChannels.add(id);
localStorage.setItem('hiddenChannels', JSON.stringify([...hiddenChannels]));
rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom();
}
/* Show exactly this group's channels, hide the rest */
function applyGroup(g) {
hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid)));
localStorage.setItem('hiddenChannels', JSON.stringify([...hiddenChannels]));
rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom();
}
function groupIsActive(g) {
const members = g.channelIds.filter((cid) => channels.has(cid));
if (!members.length) return false;
return [...channels.keys()].every((cid) =>
members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid)
);
}
function rebuildChannelMenu() {
channelMenu.innerHTML = '';
// group presets first
if (groups.size) {
for (const g of groups.values()) {
const on = groupIsActive(g);
const item = document.createElement('button');
item.className = 'channel-item group-item' + (on ? ' on' : '');
item.setAttribute('role', 'menuitemradio');
item.setAttribute('aria-checked', String(on));
const check = document.createElement('span');
check.className = 'check';
check.textContent = '✓';
const name = document.createElement('span');
name.className = 'name';
name.textContent = g.name;
item.append(check, name);
item.addEventListener('click', (e) => {
e.stopPropagation();
applyGroup(g);
});
channelMenu.appendChild(item);
}
const divider = document.createElement('div');
divider.className = 'menu-divider';
channelMenu.appendChild(divider);
}
let visibleCount = 0;
for (const [id, info] of channels) {
const on = !hiddenChannels.has(id);
if (on) visibleCount++;
const item = document.createElement('button');
item.className = 'channel-item' + (on ? ' on' : '');
item.setAttribute('role', 'menuitemcheckbox');
item.setAttribute('aria-checked', String(on));
const check = document.createElement('span');
check.className = 'check';
check.textContent = '✓';
const name = document.createElement('span');
name.className = 'name';
name.textContent = info.name;
name.style.color = info.color;
item.append(check, name);
item.addEventListener('click', (e) => {
// keep the menu open: the rebuild detaches this item, which would make
// the document-level outside-click handler think we clicked outside
e.stopPropagation();
toggleChannel(id);
});
channelMenu.appendChild(item);
}
channelBtn.textContent = visibleCount === channels.size
? 'Channels ▾'
: 'Channels (' + visibleCount + '/' + channels.size + ') ▾';
}
/* ── scrolling ──────────────────────────────────────────────────────── */
streamEl.addEventListener('scroll', () => {
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToBottom = nearBottom;
if (nearBottom) jumpBtn.hidden = true;
});
jumpBtn.addEventListener('click', () => {
pinnedToBottom = true;
jumpBtn.hidden = true;
scrollToBottom();
});
function scrollToBottom() {
streamEl.scrollTop = streamEl.scrollHeight;
}
function afterInsert(el) {
const visible = !el.classList.contains('hidden-by-filter');
if (!visible) return;
if (pinnedToBottom) scrollToBottom();
else jumpBtn.hidden = false;
}
/* ── entry rendering ────────────────────────────────────────────────── */
function channelInfo(entry) {
const known = channels.get(entry.channelId);
return {
name: (known && known.name) || entry.channelName || 'Unknown',
color: (known && known.color) || '#8b98a5',
};
}
function fmtTime(iso) {
const d = iso ? new Date(iso) : new Date();
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false });
}
function upsertEntry(entry) {
if (!entry || typeof entry.text !== 'string') return;
const id = entry.id || entry.channelId + '|' + entry.date;
let el = entryEls.get(id);
if (!el) {
emptyEl.hidden = true;
el = document.createElement('div');
el.className = 'entry';
el.dataset.channelId = entry.channelId || '';
el.style.setProperty('--ch', channelInfo(entry).color);
const time = document.createElement('span');
time.className = 'time';
time.textContent = fmtTime(entry.date);
const info = channelInfo(entry);
const avatar = document.createElement('span');
avatar.className = 'avatar';
avatar.style.color = info.color;
avatar.style.background = info.color + '2b'; // ~17% alpha tint
avatar.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
const ch = document.createElement('span');
ch.className = 'channel';
ch.textContent = info.name;
ch.style.color = info.color;
const text = document.createElement('span');
text.className = 'text';
const body = document.createElement('div');
body.className = 'body';
body.append(ch, text);
el.append(time, avatar, body);
entryEls.set(id, el);
applyFilter(el);
entriesEl.appendChild(el);
updateEntryEl(el, entry);
afterInsert(el);
} else {
updateEntryEl(el, entry);
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
}
}
function updateEntryEl(el, entry) {
el.querySelector('.text').textContent = entry.translatedText || entry.text;
el.classList.toggle('in-progress', entry.inProgress === true);
}
/* ── SSE event parsing ──────────────────────────────────────────────── */
/* The stream sends JSON objects for new/updated/completed transcript
entries. Be liberal about the envelope shape. */
function extractEntries(obj) {
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
if (obj[key] && typeof obj[key] === 'object') {
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
}
function handleEventData(raw) {
let obj;
try { obj = JSON.parse(raw); } catch { return; }
const kind = String(obj.type || obj.event || '').toLowerCase();
if (kind.includes('clear')) {
entryEls.forEach((el) => el.remove());
entryEls.clear();
emptyEl.hidden = false;
return;
}
extractEntries(obj).forEach(upsertEntry);
}
/* ── data loading ───────────────────────────────────────────────────── */
async function loadChannels() {
const res = await fetch('/prodcom/api/v1/channels');
if (!res.ok) throw new Error('channels ' + res.status);
const body = await res.json();
channels.clear();
for (const ch of body.data || []) {
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
}
rebuildChannelMenu();
}
async function loadGroups() {
const res = await fetch('/prodcom/api/v1/groups');
if (!res.ok) throw new Error('groups ' + res.status);
const body = await res.json();
groups.clear();
for (const g of body.data || []) {
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
}
rebuildChannelMenu();
}
async function loadHistory() {
const res = await fetch('/prodcom/api/v1/transcript?limit=100');
if (!res.ok) throw new Error('transcript ' + res.status);
const body = await res.json();
const entries = (body.data || []).slice();
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
entries.forEach(upsertEntry);
}
/* ── SSE connection ─────────────────────────────────────────────────── */
let es = null;
let reconnectTimer = null;
function connect() {
if (es) es.close();
es = new EventSource('/prodcom/api/v1/transcript/stream');
es.onopen = () => {
setStatus('connected', 'Live');
// refresh channels/groups and backfill entries missed while disconnected
// (upsertEntry dedupes by id, so re-fetching history is harmless)
loadChannels().catch(() => {});
loadGroups().catch(() => {});
loadHistory().catch(() => {});
};
es.onerror = () => {
setStatus('error', 'Reconnecting…');
// EventSource auto-retries transient drops, but it gives up permanently
// when a retry gets a completed non-SSE response — which is what our
// proxy's 502 looks like while ProdCom is closed. Recreate the
// connection on a timer until ProdCom is back.
if (es.readyState === EventSource.CLOSED) {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 3000);
}
};
const handler = (ev) => handleEventData(ev.data);
es.onmessage = handler;
// Also listen for likely named event types (spec doesn't pin these down)
[
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
'updated', 'complete', 'completed',
].forEach((name) => es.addEventListener(name, handler));
}
/* ── boot ───────────────────────────────────────────────────────────── */
async function boot() {
setStatus('', 'Connecting…');
try {
await loadChannels();
await loadGroups().catch(() => {}); // groups are optional
await loadHistory();
} catch {
setStatus('error', 'ProdCom unreachable — retrying…');
setTimeout(boot, 5000);
return;
}
scrollToBottom();
connect();
}
boot();
// Expose for debugging/testing in the console
window.__prodcomListener = { upsertEntry, handleEventData, channels };
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProdCom Listener</title>
<meta name="theme-color" content="#0b0e12">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ProdCom">
<link rel="stylesheet" href="/style.css?v=3">
</head>
<body>
<header id="topbar">
<span id="status-dot" class="dot" title="Connection status"></span>
<div class="controls">
<div class="dropdown" id="channel-dd">
<button id="channel-btn" title="Choose visible channels">Channels ▾</button>
<div id="channel-menu" hidden></div>
</div>
<button id="time-toggle" title="Toggle timestamps" aria-label="Toggle timestamps" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>
</svg>
</button>
<button id="font-dec" title="Smaller text">A−</button>
<button id="font-inc" title="Larger text">A+</button>
<button id="clear-btn" title="Clear this view (does not affect ProdCom)">Clear</button>
</div>
<button id="menu-handle" aria-label="Toggle menu"></button>
</header>
<main id="stream" aria-live="polite">
<div id="empty-state">Waiting for transcript…</div>
<div id="entries"></div>
</main>
<button id="jump-latest" hidden>↓ New messages</button>
<script src="/app.js?v=3"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"name": "ProdCom Listener",
"short_name": "ProdCom",
"description": "Live transcript stream from ProdCom",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b0e12",
"theme_color": "#0b0e12",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+296
View File
@@ -0,0 +1,296 @@
:root {
--bg: #0b0e12;
--panel: #131920;
--border: #232b35;
--text: #e6edf3;
--muted: #8b98a5;
--accent: #2ee59a;
--entry-size: 17px;
/* timestamp column (hidden off-viewport left by default) —
slot = time width + stream padding (16) + entry padding (8) + 2 safety */
--time-w: calc(var(--entry-size) * 3.6);
--time-slot: calc(var(--time-w) + 26px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── pull-down menubar ─────────────────────────────────────────────── */
#topbar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 14px;
background: var(--panel);
border-bottom: 1px solid var(--border);
user-select: none;
-webkit-user-select: none;
transform: translateY(-100%);
transition: transform 0.28s ease;
}
body.menu-open #topbar { transform: translateY(0); }
#menu-handle {
position: absolute;
top: 100%;
right: 100px;
width: 58px;
height: 20px;
padding: 0;
background: #1c242e;
border: 1px solid #3a4654;
border-top: none;
border-radius: 0 0 12px 12px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45);
cursor: pointer;
}
#menu-handle::after {
content: "";
display: block;
width: 24px;
height: 3px;
margin: 0 auto;
border-radius: 999px;
background: var(--muted);
}
#menu-handle:hover { background: var(--border); }
#menu-handle:hover::after { background: var(--text); }
.dot {
width: 10px; height: 10px;
border-radius: 50%;
background: #f0b429;
flex: none;
transition: background 0.3s;
}
.dot.connected { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.dot.error { background: #f4433c; }
.controls { display: flex; align-items: center; gap: 8px; }
.controls button {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
}
.controls button:hover { border-color: var(--muted); }
.controls button:active { background: var(--border); }
.controls button svg { display: block; }
.dropdown { position: relative; }
#channel-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 190px;
max-height: 60vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
.channel-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.channel-item:hover { background: var(--border); }
.channel-item .check {
width: 15px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
.channel-item.on .check { visibility: visible; }
.channel-item .name { font-weight: 600; }
.menu-divider {
height: 1px;
background: var(--border);
margin: 5px 4px;
}
#time-toggle.active {
color: var(--accent);
border-color: var(--accent);
}
/* ── scrollbars ────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
border: 2px solid transparent; /* inset gutter */
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #364253;
background-clip: padding-box;
}
::-webkit-scrollbar-corner { background: transparent; }
/* Firefox (webkit pseudo-elements unsupported there) */
@supports (-moz-appearance: none) {
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
}
/* ── stream ────────────────────────────────────────────────────────── */
#stream {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 22px 16px 24px;
overscroll-behavior: contain;
}
#empty-state {
color: var(--muted);
font-size: 14px;
text-align: center;
padding-top: 15vh;
}
/* Entries sit in a wrapper shifted left so the timestamp column lives
off-viewport. A horizontal swipe (or the clock toggle) translates it
into view; releasing snaps back. */
#entries {
margin-left: calc(-1 * var(--time-slot));
transition:
transform 0.34s cubic-bezier(0.22, 1.35, 0.32, 1),
margin-left 0.34s cubic-bezier(0.22, 1.35, 0.32, 1);
will-change: transform;
}
#entries.dragging { transition: none; }
/* Pinned: give the time column real layout space so text wraps inside the
viewport instead of sliding off the right edge. */
body.show-times #entries { margin-left: 0; }
body.show-times .entry { padding-left: 0; }
.entry {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 8px;
border-radius: 8px;
font-size: var(--entry-size);
line-height: 1.45;
animation: fade-in 0.25s ease-out;
transition: padding-left 0.34s cubic-bezier(0.22, 1.35, 0.32, 1);
}
.entry .body { flex: 1; min-width: 0; }
.entry .avatar {
flex: none;
width: calc(var(--entry-size) * 1.9);
height: calc(var(--entry-size) * 1.9);
border-radius: 26%;
display: flex;
align-items: center;
justify-content: center;
}
.entry .avatar svg { width: 66%; height: 66%; display: block; }
.entry:hover { background: var(--panel); }
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.entry .time {
color: var(--muted);
font-size: 0.72em;
font-variant-numeric: tabular-nums;
flex: none;
width: var(--time-w);
text-align: right;
padding-top: 2px;
}
.entry .channel {
display: block;
font-size: 0.82em;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1; /* text top hugs the body top, aligning with the icon */
white-space: nowrap;
margin-bottom: 5px;
}
.entry .text { display: block; overflow-wrap: break-word; }
.entry.in-progress .text { color: var(--muted); }
.entry.in-progress .text::after {
content: "▍";
color: var(--ch, var(--accent));
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.entry.hidden-by-filter { display: none; }
#jump-latest {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: #06281a;
font-weight: 600;
font-size: 13px;
border: none;
border-radius: 999px;
padding: 9px 18px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
@media (max-width: 560px) {
:root { --time-slot: calc(var(--time-w) + 20px); }
#topbar { padding: 8px 10px; }
#menu-handle { right: 60px; }
#stream { padding: 18px 10px 20px; }
}
+44
View File
@@ -0,0 +1,44 @@
'use strict';
/* Minimal service worker: caches the app shell so the PWA is installable.
Never touches /prodcom/* (the live API + SSE stream). */
const CACHE = 'prodcom-listener-v3';
const SHELL = [
'/',
'/style.css?v=3',
'/app.js?v=3',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.pathname.startsWith('/prodcom/')) return;
// Network first, cache fallback
event.respondWith(
fetch(event.request)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return res;
})
.catch(() => caches.match(event.request, { ignoreSearch: true }))
);
});
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* ProdCom Listener — serves the web UI and proxies requests to the ProdCom API
* so any browser on the network can connect without CORS issues.
*
* Zero dependencies. Configure via config.json or env vars:
* PRODCOM_URL (default: value in config.json)
* PORT (default: value in config.json)
* PRODCOM_API_KEY (only needed if PSK auth is enabled in ProdCom)
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const CONFIG_PATH = path.join(__dirname, 'config.json');
let cfg = {};
try {
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (e) {
console.warn('config.json not found or invalid, using defaults/env vars');
}
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
const PORT = Number(process.env.PORT || cfg.port || 24481);
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
const target = new URL(PRODCOM_URL);
const PUBLIC_DIR = path.join(__dirname, 'public');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
// Hop-by-hop headers that must not be forwarded
const HOP_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
function proxy(req, res) {
const upstreamPath = req.url.slice('/prodcom'.length) || '/';
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
}
headers.host = target.host;
delete headers['accept-encoding']; // keep SSE unbuffered and simple
if (API_KEY) headers.authorization = 'Bearer ' + API_KEY;
const preq = http.request(
{
hostname: target.hostname,
port: target.port || 80,
path: upstreamPath,
method: req.method,
headers,
},
(pres) => {
const outHeaders = {};
for (const [k, v] of Object.entries(pres.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
}
res.writeHead(pres.statusCode, outHeaders);
// Flush immediately — SSE streams may send no bytes for a while, and
// the browser needs the headers to consider the stream open.
res.flushHeaders();
pres.pipe(res);
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
// the browser's response, leaving SSE clients hanging on a dead
// stream. Destroy it so they notice immediately and reconnect.
pres.on('close', () => {
if (!res.writableEnded) res.destroy();
});
}
);
preq.on('error', (e) => {
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
});
// Tear down the upstream connection when the browser disconnects
res.on('close', () => preq.destroy());
req.pipe(preq);
}
function serveStatic(req, res) {
let urlPath = decodeURIComponent(req.url.split('?')[0]);
if (urlPath === '/') urlPath = '/index.html';
const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403);
return res.end('Forbidden');
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('Not found');
}
const ext = path.extname(filePath).toLowerCase();
// Icons can cache; everything else must always revalidate so UI updates
// reach installed web apps immediately.
const noCache = ext !== '.png' && ext !== '.ico' && ext !== '.svg';
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': noCache ? 'no-cache' : 'public, max-age=3600',
});
res.end(data);
});
}
const server = http.createServer((req, res) => {
if (req.url.startsWith('/prodcom/')) return proxy(req, res);
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405);
return res.end('Method not allowed');
}
serveStatic(req, res);
});
// Never time out long-lived SSE connections
server.requestTimeout = 0;
server.listen(PORT, '0.0.0.0', () => {
console.log('ProdCom Listener running');
console.log(' ProdCom API : ' + PRODCOM_URL);
console.log(' Local : http://localhost:' + PORT);
for (const ifaces of Object.values(os.networkInterfaces())) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
console.log(' Network : http://' + iface.address + ':' + PORT);
}
}
}
});
+5
View File
@@ -0,0 +1,5 @@
@echo off
rem Double-click to start ProdCom Listener (Windows)
cd /d %~dp0
node server.js
pause