Refine player control-plane flow

This commit is contained in:
2026-08-07 13:10:16 +01:00
parent 74318eb34e
commit 433be06bc7
35 changed files with 1604 additions and 233 deletions
+26
View File
@@ -0,0 +1,26 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services
DB_HOST="mysql"
DB_PORT=3306
DB_NAME="pulse-signage"
DB_USER="pulse-signage"
DB_PASSWORD="signage_password"
MYSQL_ROOT_PASSWORD="root_password"
# Player settings
PLAYER_IDENTIFIER="player-local"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
PLAYER_INTERNAL_BASE_URL="http://player:8081"
# Web app bootstrap settings
SESSION_MAX_AGE_DAYS=14
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_NAME="Admin"
DEFAULT_ADMIN_PASSWORD="admin"
PASSWORD_HASH_ITERATIONS=310000
# Bridge settings for the player-bridge service
WEB_BASE_URL="http://web:8080"
+11
View File
@@ -0,0 +1,11 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings
PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote player connectivity settings
THIN_CLIENT_BASE_URL="http://192.168.0.80:8090"
+35 -19
View File
@@ -4,10 +4,10 @@ This folder contains the public Docker Compose definitions for Pulse Signage and
## Files ## Files
- [local.yml](local.yml) - full public stack with web, player, player bridge, and MySQL. - [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL.
- [remote.yml](remote.yml) - remote player-only stack for machines that sit behind the player bridge. - [.env.example](.env.example) - sample environment values for the public stack.
- [local.env.example](local.env.example) - sample environment values for the public stack. - [docker-compose.remote.yml](docker-compose.remote.yml) - remote player-only stack for machines that sit behind the player bridge.
- [remote.env.example](remote.env.example) - sample environment values for the remote stack. - [.env.remote.example](.env.remote.example) - sample environment values for the remote stack.
## Stack Overview ## Stack Overview
@@ -85,6 +85,7 @@ Responsibilities:
Key configuration: Key configuration:
- `PULSE_SIGNAGE_SHARED_SECRET` - `PULSE_SIGNAGE_SHARED_SECRET`
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
### `mysql` ### `mysql`
@@ -105,14 +106,14 @@ Key configuration:
## Environment Files ## Environment Files
### `local.env.example` ### `.env.example`
Use this file as a starting point for the public compose stack. Use this file as a starting point for the public compose stack.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services - `PULSE_SIGNAGE_IMAGE` - image to run for all app services
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used for request authentication - `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier - `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack - `DB_*` - MySQL credentials and database name for the stack
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises - `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises
@@ -121,40 +122,54 @@ Important values:
- `DEFAULT_ADMIN_*` - bootstrap admin account values - `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost - `PASSWORD_HASH_ITERATIONS` - password hashing cost
### `remote.env.example` ### `.env.remote.example`
Use this file on a remote player device. Use this file on a remote player device.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device - `PULSE_SIGNAGE_IMAGE` - image to run on the device
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack - `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier - `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player - `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to - `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent - `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
### `PULSE_SIGNAGE_SHARED_SECRET`
This secret is the shared signing key for requests between the services. Use a single value for every service that needs to talk to the same stack, including the web app, player, bridge, and any remote player that connects back to that bridge.
Recommended shape:
- at least 32 random bytes
- ideally 64 hex characters, or another equally long cryptographically random string
- not a password, phrase, or anything human-readable
If you want a quick local value, generate one with a password manager or a command such as `openssl rand -hex 32`.
Leave it blank only if you intentionally want to run without request signing in a throwaway local setup.
## Main Configuration Variables ## Main Configuration Variables
| Variable | Used By | Purpose | | Variable | Used By | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | all services | Docker image to run for the app services. | | `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge | Shared secret for authenticated requests between services. | | `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
| `DB_HOST` | web, player, bridge | Database host name. | | `DB_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. | | `DB_PORT` | web, player, bridge | Database port. |
| `DB_NAME` | web, player, bridge | Database name. | | `DB_NAME` | web, player, bridge, mysql | Database name. |
| `DB_USER` | web, player, bridge | Database user. | | `DB_USER` | web, player, bridge, mysql | Database user. |
| `DB_PASSWORD` | web, player, bridge | Database password. | | `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. | | `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
| `PLAYER_PUBLIC_BASE_URL` | player | Public URL advertised by the player. |
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `THIN_CLIENT_BASE_URL` | player, remote player | URL of the bridge service. |
| `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. | | `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. |
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. | | `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. | | `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. | | `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. | | `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. |
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. | | `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
## Ports ## Ports
@@ -191,14 +206,15 @@ Each compose file creates its own named network:
## Notes ## Notes
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`. - The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players. - The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint. - The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. - The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases.
## Recommended Setup ## Recommended Setup
1. Copy `local.env.example` to a local `.env` file for the public stack. 1. Copy `.env.example` to a local `.env` file for the public stack.
2. Copy `remote.env.example` to a device-specific `.env` file for the remote player. 2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player.
3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere. 3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere.
4. Start the public stack first, then start the remote player after the bridge is reachable. 4. Start the public stack first, then start the remote player after the bridge is reachable.
5. Verify that the player appears in Connected clients before testing screen commands. 5. Verify that the player appears in Connected clients before testing screen commands.
@@ -9,8 +9,8 @@ services:
environment: environment:
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
@@ -39,8 +39,8 @@ services:
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes: volumes:
- pulse-signage:/app/media - pulse-signage:/app/media
@@ -57,11 +57,12 @@ services:
ports: ports:
- "8090:8090" - "8090:8090"
environment: environment:
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
command: ["node", "src/player-bridge/index.js"] command: ["node", "src/player-bridge/index.js"]
depends_on: depends_on:
@@ -76,8 +77,8 @@ services:
ports: ports:
- "3306:3306" - "3306:3306"
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_DATABASE: ${DB_NAME:-pulse-signage}
MYSQL_USER: ${DB_USER:-signage_user} MYSQL_USER: ${DB_USER:-pulse-signage}
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password} MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
command: command:
-19
View File
@@ -1,19 +0,0 @@
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
PULSE_SIGNAGE_SHARED_SECRET=
PLAYER_IDENTIFIER=player-local
DB_HOST=mysql
DB_PORT=3306
DB_NAME=signage
DB_USER=signage_user
DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
PLAYER_INTERNAL_BASE_URL=http://player:8081
SESSION_MAX_AGE_DAYS=14
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_NAME=Admin
DEFAULT_ADMIN_PASSWORD=admin
PASSWORD_HASH_ITERATIONS=310000
-8
View File
@@ -1,8 +0,0 @@
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
PULSE_SIGNAGE_SHARED_SECRET=
PLAYER_IDENTIFIER=player-remote
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
THIN_CLIENT_BASE_URL=http://192.168.0.80:8090
PLAYER_AGENT_RECONNECT_DELAY_MS=2000
+40 -2
View File
@@ -20,7 +20,36 @@ function createThinClientConfig() {
}; };
} }
function logBridge(message, details) {
if (details === undefined) {
console.info(`[player-bridge] ${message}`);
return;
}
console.info(`[player-bridge] ${message}`, details);
}
function normalizeRemoteAddress(value) {
const address = String(value || '').trim();
if (!address) {
return '';
}
return address.toLowerCase().startsWith('::ffff:') ? address.slice(7) : address;
}
function formatPlayerConnectionLabel(deviceId, remoteAddress) {
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress);
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
}
function resolveWebBaseUrl(req) { function resolveWebBaseUrl(req) {
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
if (configuredWebBaseUrl) {
return configuredWebBaseUrl;
}
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0]; const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim(); const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
if (!host) { if (!host) {
@@ -40,7 +69,12 @@ function resolveWebBaseUrl(req) {
if (url.port === '8090') { if (url.port === '8090') {
url.port = '8080'; url.port = '8080';
} else if (!url.port) { } else if (!url.port) {
url.port = '8080'; const forwardedPort = String(req && req.headers && req.headers['x-forwarded-port'] || '').trim().split(',')[0];
if (forwardedPort) {
url.port = forwardedPort === '8090' ? '8080' : forwardedPort;
} else if (protocol === 'http') {
url.port = '8080';
}
} }
return url.toString().replace(/\/$/, ''); return url.toString().replace(/\/$/, '');
@@ -600,6 +634,7 @@ async function start() {
}); });
playerSockets.set(deviceId, socket); playerSockets.set(deviceId, socket);
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`);
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player })); socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
return; return;
} }
@@ -646,11 +681,14 @@ async function start() {
} }
if (!verifyRequestAuth(request)) { if (!verifyRequestAuth(request)) {
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
socket.destroy(); socket.destroy();
return; return;
} }
playersWs.handleUpgrade(request, socket, head, function (ws) { playersWs.handleUpgrade(request, socket, head, function (ws) {
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
playersWs.emit('connection', ws, request); playersWs.emit('connection', ws, request);
}); });
}); });
@@ -868,7 +906,7 @@ async function start() {
}); });
} }
module.exports = { start: start }; module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl };
if (require.main === module) { if (require.main === module) {
start().catch(function (error) { start().catch(function (error) {
+12 -6
View File
@@ -87,6 +87,8 @@ async function start() {
} }
} }
let webMediaSyncCompleted = false;
async function handleThinClientCommand(socket, rawMessage) { async function handleThinClientCommand(socket, rawMessage) {
let payload = null; let payload = null;
try { try {
@@ -246,11 +248,14 @@ async function start() {
internalBaseUrl: PLAYER_INTERNAL_BASE_URL internalBaseUrl: PLAYER_INTERNAL_BASE_URL
})); }));
triggerWebMediaSync().then(function (success) { if (!webMediaSyncCompleted) {
webMediaSyncTriggered = Boolean(success); triggerWebMediaSync().then(function (success) {
}).catch(function () { webMediaSyncTriggered = Boolean(success);
webMediaSyncTriggered = false; webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}); }).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () { heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
@@ -263,9 +268,10 @@ async function start() {
internalBaseUrl: PLAYER_INTERNAL_BASE_URL internalBaseUrl: PLAYER_INTERNAL_BASE_URL
})); }));
if (!webMediaSyncTriggered) { if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) { triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success); webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () { }).catch(function () {
webMediaSyncTriggered = false; webMediaSyncTriggered = false;
}); });
+27 -30
View File
@@ -263,20 +263,33 @@ function renderSlideMarkup(markup, shouldFade) {
}); });
} }
function schedulePostRenderSetup(root, delayMs) {
if (!root) {
return;
}
if (root.isConnected === false) {
return;
}
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(root);
}
initializeRenderedVideoPlayback(root, delayMs);
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
}
}
if (!shouldFade) { if (!shouldFade) {
app.innerHTML = markup; app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') { schedulePostRenderSetup(app, 0);
syncRtmpRegions(app);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
return app.firstElementChild; return app.firstElementChild;
} }
@@ -325,11 +338,7 @@ function renderSlideMarkup(markup, shouldFade) {
app.innerHTML = ''; app.innerHTML = '';
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
app.appendChild(nextShell); app.appendChild(nextShell);
if (typeof syncRtmpRegions === 'function') { schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell; return nextShell;
} }
@@ -340,24 +349,12 @@ function renderSlideMarkup(markup, shouldFade) {
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2); pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell); app.appendChild(nextShell);
void nextShell.offsetHeight;
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
previousShell.style.opacity = '0'; previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') { schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
playRegionAnimations(nextShell, 'intro');
}
}); });
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) { if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell); previousShell.parentNode.removeChild(previousShell);
@@ -54,6 +54,9 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
index = currentIndex; index = currentIndex;
var markup = buildSlideMarkup(slide); var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides); renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
sendCommandState(slide); sendCommandState(slide);
if (!isPaused) { if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
@@ -90,6 +93,9 @@ function showCurrent() {
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(activeSlides, index); syncRtmpWarmups(activeSlides, index);
} }
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(activeSlides, index);
}
if (!activeSlides.length) { if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
@@ -211,6 +217,9 @@ function refresh() {
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(nextActiveSlides, index); syncRtmpWarmups(nextActiveSlides, index);
} }
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(nextActiveSlides, index);
}
if (nextActiveSlides.length < 2) { if (nextActiveSlides.length < 2) {
pendingPlaylistUpdate = { pendingPlaylistUpdate = {
slides: nextSlides, slides: nextSlides,
+58 -6
View File
@@ -84,7 +84,7 @@ function getCurrentRenderKey(activeSlides) {
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|'); return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
} }
// Pick the current slide and the next slide for webpage preloading. // Pick the next slide for webpage preloading.
function getWebpagePreloadSlides(sourceSlides, targetIndex) { function getWebpagePreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) { if (!availableSlides.length) {
@@ -97,19 +97,71 @@ function getWebpagePreloadSlides(sourceSlides, targetIndex) {
} }
const preloadSlides = []; const preloadSlides = [];
const currentSlide = availableSlides[normalizedIndex];
const nextSlide = availableSlides[normalizedIndex + 1]; const nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) { if (nextSlide) {
preloadSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
preloadSlides.push(nextSlide); preloadSlides.push(nextSlide);
} }
return preloadSlides; return preloadSlides;
} }
// Pick the next slide to warm its markup before it becomes visible.
function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const preloadSlides = [];
const nextSlide = availableSlides[normalizedIndex + 1];
if (nextSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
if (window.__pulseThumbnailPreview) {
return;
}
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
return;
}
const slide = preloadSlides[0];
const preloadSignature = [
currentPlaylistSignature || '',
slide && slide.id ? slide.id : '',
slide && slide.template_id ? slide.template_id : '',
slide && slide.modified_at ? slide.modified_at : '',
window.innerWidth + 'x' + window.innerHeight,
videoRegionRenderVersion || 0
].join('|');
if (scheduleSlideMarkupPreload.signature === preloadSignature) {
return;
}
scheduleSlideMarkupPreload.signature = preloadSignature;
window.setTimeout(function () {
if (scheduleSlideMarkupPreload.signature !== preloadSignature) {
return;
}
primeSlideMarkup(slide);
}, 0);
}
// Mount hidden iframe preloads for the chosen webpage URLs. // Mount hidden iframe preloads for the chosen webpage URLs.
function syncWebpagePreloads(sourceSlides, targetIndex) { function syncWebpagePreloads(sourceSlides, targetIndex) {
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex)); const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
+49 -3
View File
@@ -893,6 +893,45 @@ function getSlideMarkupCacheKey(slide) {
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|'); return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
} }
function restorePlayerCanvasDimensions(width, height) {
if (!document || !document.documentElement) {
return;
}
var style = document.documentElement.style;
if (width) {
style.setProperty('--player-canvas-width', width);
} else {
style.removeProperty('--player-canvas-width');
}
if (height) {
style.setProperty('--player-canvas-height', height);
} else {
style.removeProperty('--player-canvas-height');
}
}
function primeSlideMarkup(slide) {
if (!slide) {
return '';
}
var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) {
return cachedMarkup;
}
var previousWidth = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-width') || '') : '';
var previousHeight = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-height') || '') : '';
try {
return buildSlideMarkupForState(slide, false);
} finally {
restorePlayerCanvasDimensions(previousWidth.trim(), previousHeight.trim());
}
}
function notifyVideoRegionSourceReady() { function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') { if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1; videoRegionRenderVersion += 1;
@@ -919,9 +958,12 @@ function setCachedSlideMarkup(slide, markup) {
// Slide rendering and markup cache helpers. // Slide rendering and markup cache helpers.
// Choose the right slide renderer and cache the result. // Choose the right slide renderer and cache the result.
function buildSlideMarkup(slide) { function buildSlideMarkupForState(slide, updateCurrentState) {
lastRenderedSlide = slide || null; if (updateCurrentState) {
syncBlackoutState(); lastRenderedSlide = slide || null;
syncBlackoutState();
}
var cachedMarkup = getCachedSlideMarkup(slide); var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) { if (cachedMarkup) {
return cachedMarkup; return cachedMarkup;
@@ -938,3 +980,7 @@ function buildSlideMarkup(slide) {
setCachedSlideMarkup(slide, markup); setCachedSlideMarkup(slide, markup);
return markup; return markup;
} }
function buildSlideMarkup(slide) {
return buildSlideMarkupForState(slide, true);
}
+5 -10
View File
@@ -304,13 +304,9 @@ function getRtmpWarmupSlides(sourceSlides, targetIndex) {
} }
var warmupSlides = []; var warmupSlides = [];
var currentSlide = availableSlides[normalizedIndex];
var nextSlide = availableSlides[normalizedIndex + 1]; var nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) { if (nextSlide) {
warmupSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
warmupSlides.push(nextSlide); warmupSlides.push(nextSlide);
} }
@@ -693,12 +689,11 @@ function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, plac
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({ var hls = new window.Hls({
enableWorker: true, enableWorker: true,
lowLatencyMode: true, liveSyncDurationCount: 6,
liveSyncDurationCount: 4, liveMaxLatencyDurationCount: 12,
liveMaxLatencyDurationCount: 8, maxBufferLength: 30,
maxBufferLength: 20,
maxLiveSyncPlaybackRate: 1, maxLiveSyncPlaybackRate: 1,
backBufferLength: 30 backBufferLength: 60
}); });
video.__rtmpHls = hls; video.__rtmpHls = hls;
hls.attachMedia(video); hls.attachMedia(video);
+3 -3
View File
@@ -122,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
setVideoSourceAvailability(requestedSrc, true); setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
var requestedState = getVideoSourceAvailability(requestedSrc); var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -132,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) { if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
scheduleVideoSourceProbe(regionKey, requestedSrc, false); scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -141,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) { if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc); logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
} }
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
return ''; return '';
@@ -18,50 +18,57 @@ function registerFontSweepTask(options) {
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
throw new Error('registerFontSweepTask requires the font sweep dependencies.'); throw new Error('registerFontSweepTask requires the font sweep dependencies.');
} }
backgroundTaskQueue.registerRecurringTask({ const metadataPromise = typeof getPlayerTaskMetadata === 'function'
key: TASK.key, ? Promise.resolve(getPlayerTaskMetadata())
title: TASK.title, : Promise.resolve({});
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {
mediaDir: mediaDir
},
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
for (let i = 0; i < desiredOperations.length; i += 1) { return metadataPromise.then(function (metadata) {
const operation = desiredOperations[i] || {}; backgroundTaskQueue.registerRecurringTask({
const uploadPath = String(operation.uploadPath || '').trim(); key: TASK.key,
if (!uploadPath) { title: TASK.title,
continue; category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: Object.assign({
mediaDir: mediaDir
}, metadata || {}),
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
const uploadPath = String(operation.uploadPath || '').trim();
if (!uploadPath) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await removeUploadFileFromPlayer(uploadPath, mediaDir);
} else {
await pushUploadFileToPlayer(uploadPath, mediaDir);
}
} }
if (String(operation.type || '').trim().toLowerCase() === 'delete') { for (let i = 0; i < currentUploadPaths.length; i += 1) {
const uploadPath = String(currentUploadPaths[i] || '').trim();
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
continue;
}
await removeUploadFileFromPlayer(uploadPath, mediaDir); await removeUploadFileFromPlayer(uploadPath, mediaDir);
} else {
await pushUploadFileToPlayer(uploadPath, mediaDir);
} }
} }
});
for (let i = 0; i < currentUploadPaths.length; i += 1) {
const uploadPath = String(currentUploadPaths[i] || '').trim();
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
continue;
}
await removeUploadFileFromPlayer(uploadPath, mediaDir);
}
}
}); });
} }
@@ -8,22 +8,30 @@ const TASK = {
function registerInitialFontSyncTask(options) { function registerInitialFontSyncTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
} }
return backgroundTaskQueue.enqueueTask({ const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
key: TASK.key, ? uploadSyncService.getPlayerTaskMetadata()
title: 'Initial font sync', : Promise.resolve({});
category: TASK.category,
taskType: 'font-sync', return Promise.resolve(metadataPromise).then(function (metadata) {
payload: { return backgroundTaskQueue.enqueueTask({
mode: 'initial', key: TASK.key,
uploadDir: mediaDir, title: 'Initial font sync',
operations: collectFontLibrarySyncOperations(mediaDir) category: TASK.category,
}, taskType: 'font-sync',
persist: true metadata: Object.assign({}, metadata || {}),
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: collectFontLibrarySyncOperations(mediaDir)
},
persist: true
});
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial font sync:', error); console.warn('Unable to queue initial font sync:', error);
}); });
@@ -6,21 +6,29 @@ const TASK = {
function registerInitialMediaSyncTask(options) { function registerInitialMediaSyncTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
} }
return backgroundTaskQueue.enqueueTask({ const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
key: TASK.key, ? uploadSyncService.getPlayerTaskMetadata()
title: 'Initial media sync', : Promise.resolve({});
category: TASK.category,
taskType: 'media-sync', return Promise.resolve(metadataPromise).then(function (metadata) {
payload: { return backgroundTaskQueue.enqueueTask({
mode: 'initial', key: TASK.key,
uploadDir: mediaDir title: 'Initial media sync',
}, category: TASK.category,
persist: true taskType: 'media-sync',
metadata: Object.assign({}, metadata || {}),
payload: {
mode: 'initial',
uploadDir: mediaDir
},
persist: true
});
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial media sync:', error); console.warn('Unable to queue initial media sync:', error);
}); });
+146 -24
View File
@@ -6,12 +6,40 @@ const crypto = require('crypto');
const multer = require('multer'); const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth'); const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library'); const { collectFontLibrarySyncOperations } = require('./font-library');
const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry'); const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) { function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim()); return path.resolve(String(uploadDir || '').trim());
} }
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
function createUploadSyncService(options) { function createUploadSyncService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
@@ -21,54 +49,120 @@ function createUploadSyncService(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024; const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const MAX_FIELD_BYTES = 10 * 1024 * 1024; const MAX_FIELD_BYTES = 10 * 1024 * 1024;
const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000;
const pendingPlayerUploadSyncs = new Map(); const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null; let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null; let pendingPlayerUploadSyncFlushInFlight = null;
let pendingPlayerUploadSyncRetryLogAt = 0;
const pendingPlaylistUploadSyncs = new Map(); const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null; let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null; let pendingPlaylistUploadSyncFlushInFlight = null;
let playerInternalBaseUrl = null; let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null; let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') { if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.'); throw new Error('createUploadSyncService requires the upload dependencies.');
} }
async function getPlayerInternalBaseUrl() { async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) { const metadata = await getPlayerTaskMetadata();
return playerInternalBaseUrl; return metadata && metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
}
async function getPlayerTaskMetadata() {
if (playerTaskMetadata) {
return playerTaskMetadata;
} }
if (playerInternalBaseUrlPromise) { if (playerTaskMetadataPromise) {
return playerInternalBaseUrlPromise; return playerTaskMetadataPromise;
} }
playerInternalBaseUrlPromise = (async function () { playerTaskMetadataPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
try { try {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); if (pool && typeof fetchPlayerRegistrations === 'function') {
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier); const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, ''); const players = await fetchPlayerRegistrations(pool);
if (resolvedBaseUrl) { const exactPlayer = Array.isArray(players)
playerInternalBaseUrl = resolvedBaseUrl; ? players.find(function (player) {
return resolvedBaseUrl; return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
})
: null;
const registeredPlayers = Array.isArray(players) ? players : [];
const preferredPlayer = registeredPlayers.find(function (player) {
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || exactPlayer || registeredPlayers[0] || null;
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
if (resolvedInternalBaseUrl || resolvedPublicBaseUrl || resolvedIdentifier) {
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: resolvedIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null
};
return playerTaskMetadata;
}
} }
} catch (_error) { } catch (_error) {
} }
return configuredPlayerInternalBaseUrl || null; playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null;
})().then(function (baseUrl) { playerTaskMetadata = {
playerInternalBaseUrlPromise = null; playerIdentifier: getConfiguredPlayerIdentifier() || null,
return baseUrl || null; playerPublicBaseUrl: null,
playerInternalBaseUrl: playerInternalBaseUrl,
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null
};
return playerTaskMetadata;
})().then(function (metadata) {
playerTaskMetadataPromise = null;
return metadata || null;
}, function () { }, function () {
playerInternalBaseUrlPromise = null; playerTaskMetadataPromise = null;
return configuredPlayerInternalBaseUrl || null; return {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null
};
}); });
return playerInternalBaseUrlPromise; return playerTaskMetadataPromise;
}
function formatPlayerTaskLabel(metadata) {
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
if (playerLabel) {
return playerLabel;
}
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
if (playerIdentifier) {
return playerIdentifier;
}
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
if (playerPublicBaseUrl) {
return playerPublicBaseUrl;
}
return '';
}
function logMediaSyncSummary(level, message, metadata, details) {
const suffix = formatPlayerTaskLabel(metadata);
const logger = level === 'warn' ? console.warn : console.info;
if (details !== undefined) {
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details);
return;
}
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`);
} }
function createUploadMiddleware(uploadDir) { function createUploadMiddleware(uploadDir) {
@@ -561,6 +655,10 @@ function createUploadSyncService(options) {
pendingPlaylistUploadSyncs.delete(operation.key); pendingPlaylistUploadSyncs.delete(operation.key);
} }
if (pendingEntries.length) {
logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata);
}
})().finally(function () { })().finally(function () {
pendingPlaylistUploadSyncFlushInFlight = null; pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) { if (pendingPlaylistUploadSyncs.size) {
@@ -582,6 +680,11 @@ function createUploadSyncService(options) {
pendingPlayerUploadSyncFlushInFlight = (async function () { pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
? pendingEntries[0].metadata
: await getPlayerTaskMetadata();
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) { for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i]; const operation = pendingEntries[i];
let success = false; let success = false;
@@ -591,9 +694,25 @@ function createUploadSyncService(options) {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
} }
if (success) { if (success) {
successCount += 1;
pendingPlayerUploadSyncs.delete(operation.uploadPath); pendingPlayerUploadSyncs.delete(operation.uploadPath);
} else {
failureCount += 1;
} }
} }
if (successCount) {
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
}
if (failureCount) {
const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now;
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
}
} else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0;
}
})().finally(function () { })().finally(function () {
pendingPlayerUploadSyncFlushInFlight = null; pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) { if (pendingPlayerUploadSyncs.size) {
@@ -689,12 +808,14 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation); safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool; delete safePayload.operation.pool;
} }
const playerMetadata = await getPlayerTaskMetadata();
const definition = { const definition = {
key: taskKey, key: taskKey,
title: title, title: title,
category: 'media-sync', category: 'media-sync',
taskType: 'media-sync', taskType: 'media-sync',
metadata: Object.assign({}, playerMetadata || {}),
payload: safePayload, payload: safePayload,
persist: true persist: true
}; };
@@ -732,7 +853,8 @@ function createUploadSyncService(options) {
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs, flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs, flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask, runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask queueMediaSyncTask: queueMediaSyncTask,
getPlayerTaskMetadata: getPlayerTaskMetadata
}; };
} }
+49 -16
View File
@@ -1,5 +1,29 @@
const { createRequestAuthHeaders } = require('#src/request-auth'); const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry'); const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function createPlayerActionService(options) { function createPlayerActionService(options) {
const pool = options && options.pool; const pool = options && options.pool;
@@ -14,10 +38,6 @@ function createPlayerActionService(options) {
let playerInternalBaseUrlPromise = null; let playerInternalBaseUrlPromise = null;
async function getPlayerInternalBaseUrl() { async function getPlayerInternalBaseUrl() {
if (configuredPlayerInternalBaseUrl) {
return configuredPlayerInternalBaseUrl;
}
if (playerInternalBaseUrl) { if (playerInternalBaseUrl) {
return playerInternalBaseUrl; return playerInternalBaseUrl;
} }
@@ -27,22 +47,35 @@ function createPlayerActionService(options) {
} }
playerInternalBaseUrlPromise = (async function () { playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
try { try {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); if (pool && typeof fetchPlayerRegistrations === 'function') {
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier); const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, ''); const players = await fetchPlayerRegistrations(pool);
if (resolvedBaseUrl) { const exactPlayer = Array.isArray(players)
playerInternalBaseUrl = resolvedBaseUrl; ? players.find(function (player) {
return resolvedBaseUrl; return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
})
: null;
const registeredPlayers = Array.isArray(players) ? players : [];
const preferredPlayer = registeredPlayers.find(function (player) {
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || exactPlayer || registeredPlayers[0] || null;
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
if (resolvedBaseUrl) {
playerInternalBaseUrl = resolvedBaseUrl;
return resolvedBaseUrl;
}
} }
} catch (_error) { } catch (_error) {
} }
return configuredPlayerInternalBaseUrl || null; if (configuredPlayerInternalBaseUrl) {
playerInternalBaseUrl = configuredPlayerInternalBaseUrl;
return configuredPlayerInternalBaseUrl;
}
return null;
})().then(function (baseUrl) { })().then(function (baseUrl) {
playerInternalBaseUrlPromise = null; playerInternalBaseUrlPromise = null;
return baseUrl || null; return baseUrl || null;
+38 -15
View File
@@ -7,6 +7,8 @@
var setButtonVariant = webUiHelpers.setButtonVariant; var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var latestDashboardState = null; var latestDashboardState = null;
var ALL_SCREENS_SLUG = '__all__';
var ALL_SCREENS_LABEL = 'All screens';
function getClientMoveModalElements() { function getClientMoveModalElements() {
return { return {
@@ -507,13 +509,22 @@
} }
select.disabled = false; select.disabled = false;
if (!select.value || !screenBySlug[select.value]) {
select.value = screens[0].slug || '';
}
var selectedScreen = screenBySlug[select.value] || screens[0]; var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim(); var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG;
var selectedScreen = isAllSelected
? {
slug: ALL_SCREENS_SLUG,
name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL
}
: screenBySlug[select.value] || null;
var selectedSlug = isAllSelected
? ALL_SCREENS_SLUG
: String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) { var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
if (isAllSelected) {
return true;
}
return String(client && client.screen_slug || '').trim() === selectedSlug; return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : []; }) : [];
var connectionCount = selectedClients.length; var connectionCount = selectedClients.length;
@@ -532,12 +543,20 @@
pill.textContent = connectionLabel; pill.textContent = connectionLabel;
} }
if (nameNode) { if (nameNode) {
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen'); nameNode.textContent = selectedScreen
? String(selectedScreen.name || 'Selected screen')
: 'Select a target screen group';
} }
if (metaNode) { if (metaNode) {
metaNode.textContent = 'Commands sent here target every client currently using this screen.'; metaNode.textContent = !selectedSlug
? 'Choose a screen group before sending commands.'
: isAllSelected
? 'Commands sent here target every client across every screen group.'
: 'Commands sent here target every client currently using this screen.';
} }
var commandTargetSlug = selectedSlug || '';
forms.forEach(function (form) { forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase(); var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]'); var commandInput = form.querySelector('input[name="command"]');
@@ -550,10 +569,12 @@
pauseStateInput.value = allPaused ? 'false' : 'true'; pauseStateInput.value = allPaused ? 'false' : 'true';
} }
if (button) { if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen'); button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen'));
} }
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?'); form.setAttribute('data-confirm-message', allPaused
? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?')
: (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?'));
} else if (command === 'blackout') { } else if (command === 'blackout') {
commandInput.value = 'blackout'; commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]'); var blackoutStateInput = form.querySelector('input[name="blackout"]');
@@ -561,23 +582,25 @@
blackoutStateInput.value = allBlackout ? 'false' : 'true'; blackoutStateInput.value = allBlackout ? 'false' : 'true';
} }
if (button) { if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen'); button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen'));
} }
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?'); form.setAttribute('data-confirm-message', allBlackout
? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?')
: (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?'));
} else { } else {
commandInput.value = command || commandInput.value || ''; commandInput.value = command || commandInput.value || '';
} }
} }
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#'; form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
if (command === 'reload') { if (command === 'reload') {
form.setAttribute('data-confirm-message', 'Reload selected screen?'); form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?');
if (button) { if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen'; button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
} }
} }
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) { Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !selectedSlug; control.disabled = !commandTargetSlug;
}); });
}); });
} }
+49
View File
@@ -11,6 +11,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const withClientNameReservation = deps.withClientNameReservation; const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState; const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const ALL_SCREENS_SLUG = '__all__';
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try { try {
@@ -28,6 +29,46 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Command is required' }); return res.status(400).json({ error: 'Command is required' });
} }
if (slug === ALL_SCREENS_SLUG) {
if (command === 'setclientname' || command === 'moveclient') {
return res.status(400).json({ error: 'This command requires a specific screen.' });
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC');
if (!screenRows.length) {
return res.status(404).json({ error: 'No screens found' });
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
await Promise.all(screenRows.map(function (screenRow) {
return forwardPlayerCommand(String(screenRow && screenRow.slug || '').trim(), commandPayload);
}));
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: {
id: null,
name: 'All screens',
slug: ALL_SCREENS_SLUG
},
screenSlug: slug,
command: command,
connectionId: connectionId || null,
targetScreenCount: screenRows.length,
ok: true,
allScreens: true
});
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) { if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' }); return res.status(404).json({ error: 'Screen not found' });
@@ -208,7 +249,15 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections); const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const liveConnection = Array.isArray(liveConnections)
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
})
: null;
const targetBaseUrl = String( const targetBaseUrl = String(
(liveConnection && liveConnection.playerPublicBaseUrl) ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
'' ''
).trim().replace(/\/$/, ''); ).trim().replace(/\/$/, '');
@@ -143,6 +143,30 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
return buildQueryString(nextQuery); return buildQueryString(nextQuery);
} }
function buildTaskPlayerLabel(metadata) {
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
if (playerLabel) {
return playerLabel;
}
if (playerIdentifier && playerPublicBaseUrl) {
return `${playerIdentifier} (${playerPublicBaseUrl})`;
}
if (playerIdentifier) {
return playerIdentifier;
}
if (playerPublicBaseUrl) {
return playerPublicBaseUrl;
}
return '';
}
function buildQueuePageViewModel(data, message, currentUser) { function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || []; const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -171,7 +195,10 @@ function buildQueuePageViewModel(data, message, currentUser) {
'finishedAt', 'finishedAt',
function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; } function (item) { return item && item.metadata && item.metadata.sourceId; },
function (item) { return item && item.metadata && item.metadata.playerLabel; },
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
])(task); ])(task);
}); });
const sortedTasks = sortRows(filteredTasks, function (task) { const sortedTasks = sortRows(filteredTasks, function (task) {
@@ -194,7 +221,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
return task && task.finishedAt; return task && task.finishedAt;
} }
if (sort === 'source') { if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
return String(value || '').trim(); return String(value || '').trim();
}).join(' '); }).join(' ');
} }
@@ -234,11 +261,16 @@ function buildQueuePageViewModel(data, message, currentUser) {
}); });
const visibleTasksWithSources = visibleTasks.map(function (task) { const visibleTasksWithSources = visibleTasks.map(function (task) {
const playerLabel = buildTaskPlayerLabel(task.metadata);
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':');
return Object.assign({}, task, { return Object.assign({}, task, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '', sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '', sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '' sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '',
playerLabel: playerLabel,
taskDescription: taskDescription
}); });
}); });
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId); const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
@@ -287,7 +319,10 @@ function buildScheduledPageViewModel(data, message, currentUser) {
'lastError', 'lastError',
function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; } function (item) { return item && item.metadata && item.metadata.sourceId; },
function (item) { return item && item.metadata && item.metadata.playerLabel; },
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
])(task); ])(task);
}); });
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) { const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
@@ -309,7 +344,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
}).join(' '); }).join(' ');
} }
if (sort === 'source') { if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
return String(value || '').trim(); return String(value || '').trim();
}).join(' '); }).join(' ');
} }
@@ -325,7 +360,8 @@ function buildScheduledPageViewModel(data, message, currentUser) {
backgroundTasksMenuOpen: true, backgroundTasksMenuOpen: true,
recurringTasks: sortedRecurringTasks.map(function (task) { recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, { return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs) intervalLabel: formatIntervalLabel(task.intervalMs),
playerLabel: buildTaskPlayerLabel(task.metadata)
}); });
}), }),
summary: summary, summary: summary,
@@ -65,8 +65,8 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Task"> <td data-label="Task">
<div class="fw-semibold">{{title}}</div> <div class="fw-semibold">{{title}}</div>
{{#if key}} {{#if taskDescription}}
<div class="text-muted small text-break">{{key}}</div> <div class="text-muted small text-break">{{taskDescription}}</div>
{{/if}} {{/if}}
{{#if errorMessage}} {{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div> <div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
@@ -34,6 +34,9 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Task"> <td data-label="Task">
<div class="fw-semibold">{{title}}</div> <div class="fw-semibold">{{title}}</div>
{{#if playerLabel}}
<div class="text-muted small text-break">Player: {{playerLabel}}</div>
{{/if}}
<div class="text-muted small text-break">{{key}}</div> <div class="text-muted small text-break">{{key}}</div>
</td> </td>
<td data-label="Interval">{{intervalLabel}}</td> <td data-label="Interval">{{intervalLabel}}</td>
+3 -1
View File
@@ -9,7 +9,7 @@
<div class="card card-outline card-secondary mb-4 screen-command-card"> <div class="card card-outline card-secondary mb-4 screen-command-card">
<div class="card-header"> <div class="card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Screen-level controls</h3> <h3 class="card-title">Screen Group controls</h3>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -26,6 +26,7 @@
aria-label="Target screen group" aria-label="Target screen group"
data-screen-command-select data-screen-command-select
> >
<option value="" selected disabled>Select Screen Group</option>
{{#each screens}} {{#each screens}}
<option <option
value="{{slug}}" value="{{slug}}"
@@ -34,6 +35,7 @@
data-playlist-name="{{playlist_name}}" data-playlist-name="{{playlist_name}}"
>{{name}}</option> >{{name}}</option>
{{/each}} {{/each}}
<option value="__all__" data-screen-name="All screens" data-screen-target-all="true">All Screens</option>
</select> </select>
</div> </div>
</div> </div>
+184
View File
@@ -0,0 +1,184 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
function createHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('move client rebinding redirects the live player to the target screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'source-screen' }, { slug: 'target-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
}
if (sql.includes('INSERT INTO d_onboarding_devices')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://remote-player.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'moveclient',
deviceId: 'device-123',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
connectionId: 'conn-1'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.targetScreenSlug, 'target-screen');
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.payload && entry.payload.command === 'redirect'), true);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
getScreenConnections: async () => ({ connections: [] }),
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: {
command: 'pause',
paused: 'true'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
+10 -2
View File
@@ -18,7 +18,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
status: 'completed', status: 'completed',
createdAt: '2026-08-04T10:15:00.000Z', createdAt: '2026-08-04T10:15:00.000Z',
startedAt: '2026-08-04T10:16:00.000Z', startedAt: '2026-08-04T10:16:00.000Z',
finishedAt: '2026-08-04T10:17:00.000Z' finishedAt: '2026-08-04T10:17:00.000Z',
metadata: {
playerLabel: 'Player Alpha'
}
}, },
{ {
title: 'Older task', title: 'Older task',
@@ -53,7 +56,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
key: 'refresh', key: 'refresh',
intervalMs: 60000, intervalMs: 60000,
nextRunAt: '2026-08-04T10:15:00.000Z', nextRunAt: '2026-08-04T10:15:00.000Z',
lastRunAt: '2026-08-04T09:15:00.000Z' lastRunAt: '2026-08-04T09:15:00.000Z',
metadata: {
playerIdentifier: 'Player Beta'
}
} }
], ],
summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 } summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 }
@@ -66,7 +72,9 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/); assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
assert.match(queueHtml, /data-local-datetime-format="24h"/); assert.match(queueHtml, /data-local-datetime-format="24h"/);
assert.match(queueHtml, /Player Alpha:secret-key/);
assert.doesNotMatch(queueKeySearchHtml, /Example task/); assert.doesNotMatch(queueKeySearchHtml, /Example task/);
assert.match(queueDateSearchHtml, /Example task/); assert.match(queueDateSearchHtml, /Example task/);
assert.match(scheduledHtml, /data-local-datetime-format="24h"/); assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
assert.match(scheduledHtml, /Player:\s+Player Beta/);
}); });
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer a remote FQDN registration over a local player target', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
headers: {
get() {
return null;
}
},
async json() {
return { ok: true };
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
}
});
try {
const response = await playerActionService.forwardPlayerCommand('demo', { command: 'refresh' });
assert.deepEqual(response, { ok: true });
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/screens/demo/commands');
} finally {
global.fetch = originalFetch;
}
});
+45
View File
@@ -0,0 +1,45 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
delete process.env.WEB_BASE_URL;
} else {
process.env.WEB_BASE_URL = originalWebBaseUrl;
}
});
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://web.example.test/app');
});
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
delete process.env.WEB_BASE_URL;
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://bridge.example.test');
});
+148
View File
@@ -0,0 +1,148 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
function createStyleStore() {
const values = Object.create(null);
return {
values,
getPropertyValue(name) {
return values[name] || '';
},
setProperty(name, value) {
values[name] = String(value || '');
},
removeProperty(name) {
delete values[name];
}
};
}
test('primeSlideMarkup restores the current canvas dimensions', () => {
const style = createStyleStore();
style.setProperty('--player-canvas-width', '1920px');
style.setProperty('--player-canvas-height', '1080px');
const sandbox = {
window: null,
document: {
documentElement: { style }
},
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
currentPlaylistSignature: 'signature',
currentPlaylistEtag: '',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
lastRenderedSlide: { id: 1 },
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
window: null,
innerWidth: 1280,
innerHeight: 720,
pulsePlayerRegionTypes: {
get() {
return null;
}
},
syncRenderCacheViewport() {},
syncBlackoutState() {},
setPlayerCanvasDimensions() {},
escapeHtml(value) {
return String(value || '');
}
};
sandbox.window = sandbox;
const renderingPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
loadScript(renderingPath, sandbox);
const slide = {
id: 2,
kind: 'image',
media_url: 'https://example.com/next.jpg',
modified_at: '2026-08-07T00:00:00.000Z'
};
const markup = sandbox.primeSlideMarkup(slide);
assert.match(markup, /<img src="https:\/\/example\.com\/next\.jpg"/);
assert.equal(style.getPropertyValue('--player-canvas-width'), '1920px');
assert.equal(style.getPropertyValue('--player-canvas-height'), '1080px');
assert.deepEqual(sandbox.lastRenderedSlide, { id: 1 });
});
test('scheduleSlideMarkupPreload warms the next slide only', () => {
const timers = [];
const calls = [];
const sandbox = {
window: null,
document: {
documentElement: { style: createStyleStore() }
},
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
currentPlaylistSignature: 'signature',
currentPlaylistEtag: '',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
innerWidth: 1280,
innerHeight: 720,
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
escapeHtml(value) {
return String(value || '');
},
syncRenderCacheViewport() {},
syncBlackoutState() {},
setPlayerCanvasDimensions() {},
primeSlideMarkup(slide) {
calls.push(slide.id);
return 'markup:' + slide.id;
},
setTimeout(fn) {
timers.push(fn);
return timers.length;
}
};
sandbox.window = sandbox;
const playlistPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
loadScript(playlistPath, sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
sandbox.scheduleSlideMarkupPreload([current, next, later], 0);
assert.equal(timers.length, 1);
timers.shift()();
assert.deepEqual(calls, [2]);
});
@@ -0,0 +1,130 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) {
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
}
function createSandbox() {
const rafCallbacks = [];
const calls = {
syncRtmpRegions: 0,
initRegion: 0,
playRegionAnimations: 0
};
const app = {
children: [],
firstElementChild: null,
innerHTMLValue: '',
classList: {
contains() {
return false;
}
},
set innerHTML(value) {
this.innerHTMLValue = String(value || '');
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
},
get innerHTML() {
return this.innerHTMLValue;
},
appendChild(node) {
if (node) {
node.isConnected = true;
this.children.push(node);
this.firstElementChild = this.firstElementChild || node;
}
return node;
},
querySelectorAll() {
return [];
}
};
const sandbox = {
window: null,
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
requestAnimationFrame(callback) {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
app,
slides: [],
index: 0,
currentPlaylistSignature: 'signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
slideTransitionTimer: null,
slideFadeDurationMs: 560,
slideExpiresAt: null,
pausedRemainingMs: null,
timer: null,
lastRenderedSlide: null,
destroyRtmpRegions() {},
syncRtmpRegions() {
calls.syncRtmpRegions += 1;
},
initializeRegionInstances() {
calls.initializeRegionInstances += 1;
},
playRegionAnimations() {
calls.playRegionAnimations += 1;
},
pulsePlayerRegionTypes: {
list() {
return [
{
definition: {
initRegion() {
calls.initRegion += 1;
}
}
}
];
}
},
isThumbnailPreview() {
return false;
}
};
sandbox.window = sandbox;
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
return { sandbox, calls, rafCallbacks, app };
}
test('renderSlideMarkup runs post-render setup immediately', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox);
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
assert.equal(app.innerHTMLValue, '<div class="slide">visible</div>');
assert.equal(calls.syncRtmpRegions, 1);
assert.equal(calls.initRegion, 1);
assert.equal(calls.playRegionAnimations, 1);
assert.equal(rafCallbacks.length, 0);
assert.equal(returned, app.firstElementChild);
});
+98
View File
@@ -0,0 +1,98 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
test('webpage preloading only targets the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
escapeHtml(value) {
return String(value || '');
},
currentPlaylistSignature: 'signature',
slides: [],
index: 0,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
preloadSignature: '',
preloadContainer: null
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
test('rtmp warmups only target the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
fetch() {
throw new Error('not expected');
},
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
slideMarkupCache: Object.create(null),
slides: [],
index: 0,
setTimeout,
clearTimeout,
document: {
createElement() {
return {
className: '',
setAttribute() {},
appendChild() {},
parentNode: null,
addEventListener() {}
};
},
body: {
appendChild() {}
}
},
pulsePlayerRegionTypes: {
register() {}
}
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'regions', 'rtmp.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
+103
View File
@@ -45,4 +45,107 @@ test('multipart uploads accept large text fields used by slide and template form
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));
fs.rmSync(uploadDir, { recursive: true, force: true }); fs.rmSync(uploadDir, { recursive: true, force: true });
} }
});
test('fqdn player registration wins over a local configured player target for media sync', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync retry warnings include the resolved player label', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
const error = new Error('getaddrinfo ENOTFOUND player-remote');
error.code = 'ENOTFOUND';
throw error;
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
}); });
+137
View File
@@ -218,3 +218,140 @@ test('dashboard kiosk launcher requires both confirmation and a player selection
assert.equal(downloadLink.classList.contains('disabled'), false); assert.equal(downloadLink.classList.contains('disabled'), false);
assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example'); assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example');
}); });
test('screen controls include an all screens option and update the target summary', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const select = {
value: '__all__',
selectedIndex: 2,
options: [
{ value: 'alpha', textContent: 'Alpha', getAttribute() { return null; } },
{ value: 'beta', textContent: 'Beta', getAttribute() { return null; } },
{ value: '__all__', textContent: 'All screens', getAttribute(name) { return name === 'data-screen-target-all' ? 'true' : null; } }
],
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const commandInput = { value: '' };
const pausedInput = { value: 'true' };
const button = {
innerHTML: '',
disabled: false
};
const form = {
dataset: {},
getAttribute(name) {
if (name === 'data-screen-command-action') {
return 'pause';
}
return this[name] || '';
},
querySelector(selector) {
if (selector === 'input[name="command"]') {
return commandInput;
}
if (selector === 'input[name="paused"]') {
return pausedInput;
}
if (selector === 'button[type="submit"]') {
return button;
}
return null;
},
querySelectorAll() {
return [button, commandInput, pausedInput];
},
setAttribute(name, value) {
this[name] = value;
},
action: ''
};
const pill = { classList: { toggle() {}, add() {}, remove() {} }, textContent: '' };
const nameNode = { textContent: '' };
const metaNode = { textContent: '' };
const context = {
document: {
getElementById(id) {
if (id === 'screen-command-select') {
return select;
}
return null;
},
querySelector(selector) {
if (selector === '[data-screen-command-pill]') {
return pill;
}
if (selector === '[data-screen-command-name]') {
return nameNode;
}
if (selector === '[data-screen-command-meta]') {
return metaNode;
}
return null;
},
querySelectorAll(selector) {
return selector === '[data-screen-command-form]' ? [form] : [];
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
clients: [
{ screen_slug: 'alpha', paused: true },
{ screen_slug: 'beta', paused: true }
]
});
assert.equal(form.action, '/clients/__all__/commands');
assert.equal(nameNode.textContent, 'All screens');
assert.equal(metaNode.textContent, 'Commands sent here target every client across every screen group.');
assert.match(button.innerHTML, /Resume all screens/);
assert.equal(commandInput.value, 'pause');
assert.equal(pausedInput.value, 'false');
});