Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "auto"
|
||||||
|
},
|
||||||
|
"skipAutoPermissionPrompt": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"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)",
|
||||||
|
"Bash(grep -niE \"\\\\.\\(tile|card|cell|person|slot|name|label|photo|badge|pill\\)[ .{:,]\" styles.css)",
|
||||||
|
"Bash(cd \"/Users/Studio/Library/Application Support/Band Lineup\" 2>/dev/null && ls -la *.json 2>/dev/null; echo \"---\"; cat pco-credentials.json 2>/dev/null | head -20)",
|
||||||
|
"Read(//Users/Studio/Library/Application Support/**)",
|
||||||
|
"Read(//Users/Studio/Library/Application Support/Electron/**)",
|
||||||
|
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(json.dumps\\(d.get\\('proPresenter',{}\\),indent=1\\)[:200]\\); print\\('pco key present:', 'pco' in json.dumps\\(d\\).lower\\(\\)\\)\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Band Lineup — source only. Runtime state (photos, PCO and ProPresenter
|
||||||
|
# credentials, processed images) is written to the Electron userData
|
||||||
|
# directory, never here.
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# tmp/ is kept on purpose: pp-mock.js is the ProPresenter mock the ProdDash
|
||||||
|
# repo's CLAUDE.md tells people to run.
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
<!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-test-birthdays">Test Birthdays</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-status pco-birthday-result" id="pco-birthday-result"></div>
|
||||||
|
|
||||||
|
<div class="bday-block">
|
||||||
|
<div class="bday-head">
|
||||||
|
<span class="bday-title">Birthday Mode 🎂</span>
|
||||||
|
<label class="bday-switch">
|
||||||
|
<input type="checkbox" id="bday-enabled" />
|
||||||
|
<span class="bday-switch-label">On</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="bday-controls" id="bday-controls">
|
||||||
|
<div class="bday-line">
|
||||||
|
<span class="bday-line-label">Intensity</span>
|
||||||
|
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
|
||||||
|
<button type="button" data-int="subtle">Subtle</button>
|
||||||
|
<button type="button" data-int="full">Full</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bday-line">
|
||||||
|
<span class="bday-line-label">Force on <em>(test)</em></span>
|
||||||
|
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
|
||||||
|
<option value="">Off</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
|
||||||
|
</div>
|
||||||
|
</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 — screens around the building can’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>
|
||||||
Generated
+6056
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "band-lineup-display",
|
||||||
|
"productName": "Band Lineup",
|
||||||
|
"version": "1.3.10",
|
||||||
|
"buildNumber": "96",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
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),
|
||||||
|
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
|
||||||
|
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"
|
||||||
|
}
|
||||||
+2639
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();
|
||||||
+1332
File diff suppressed because it is too large
Load Diff
+198
@@ -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); });
|
||||||
@@ -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); });
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"id":"s00","sym":"","text":"Dios es santo hoy."},{"id":"s01","sym":"*","text":"Dios es * santo hoy."},{"id":"s02","sym":"^","text":"Dios es ^ santo hoy."},{"id":"s03","sym":"~","text":"Dios es ~ santo hoy."},{"id":"s04","sym":"#","text":"Dios es # santo hoy."},{"id":"s05","sym":"&","text":"Dios es & santo hoy."},{"id":"s06","sym":"%","text":"Dios es % santo hoy."},{"id":"s07","sym":"©","text":"Dios es © santo hoy."},{"id":"s08","sym":"@","text":"Dios es @ santo hoy."},{"id":"s09","sym":"$","text":"Dios es $ santo hoy."},{"id":"s10","sym":"/","text":"Dios es / santo hoy."},{"id":"s11","sym":"°","text":"Dios es ° santo hoy."},{"id":"s12","sym":"€","text":"Dios es € santo hoy."},{"id":"s13","sym":"£","text":"Dios es £ santo hoy."},{"id":"s14","sym":"§","text":"Dios es § santo hoy."},{"id":"s15","sym":"¶","text":"Dios es ¶ santo hoy."},{"id":"s16","sym":"+","text":"Dios es + santo hoy."},{"id":"s17","sym":"=","text":"Dios es = santo hoy."},{"id":"s18","sym":"_","text":"Dios es _ santo hoy."},{"id":"s19","sym":"[","text":"Dios es [ santo hoy."},{"id":"s20","sym":"]","text":"Dios es ] santo hoy."},{"id":"s21","sym":"(","text":"Dios es ( santo hoy."},{"id":"s22","sym":")","text":"Dios es ) santo hoy."},{"id":"s23","sym":"<","text":"Dios es < santo hoy."},{"id":"s24","sym":">","text":"Dios es > santo hoy."},{"id":"s25","sym":"\\","text":"Dios es \\ santo hoy."},{"id":"s26","sym":"{","text":"Dios es { santo hoy."},{"id":"s27","sym":"}","text":"Dios es } santo hoy."},{"id":"s28","sym":"\"","text":"Dios es \" santo hoy."},{"id":"s29","sym":"«","text":"Dios es « santo hoy."},{"id":"s30","sym":"•","text":"Dios es • santo hoy."},{"id":"s31","sym":"×","text":"Dios es × santo hoy."},{"id":"s32","sym":"÷","text":"Dios es ÷ santo hoy."},{"id":"s33","sym":"¥","text":"Dios es ¥ santo hoy."},{"id":"s34","sym":"№","text":"Dios es № santo hoy."},{"id":"s35","sym":"…","text":"Dios es … santo hoy."},{"id":"s36","sym":"¡","text":"Dios es ¡ santo hoy."},{"id":"s37","sym":"¿","text":"Dios es ¿ santo hoy."},{"id":"s38","sym":"|","text":"Dios es | santo hoy."},{"id":"s39","sym":"`","text":"Dios es ` santo hoy."},{"id":"s40","sym":"'","text":"Dios es ' santo hoy."},{"id":"s41","sym":"’","text":"Dios es ’ santo hoy."},{"id":"s42","sym":"-","text":"Dios es - santo hoy."},{"id":"s43","sym":"—","text":"Dios es — santo hoy."}]
|
||||||
Reference in New Issue
Block a user