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
- [local.yml](local.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.
- [local.env.example](local.env.example) - sample environment values for the public stack.
- [remote.env.example](remote.env.example) - sample environment values for the remote stack.
- [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL.
- [.env.example](.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.
- [.env.remote.example](.env.remote.example) - sample environment values for the remote stack.
## Stack Overview
@@ -85,6 +85,7 @@ Responsibilities:
Key configuration:
- `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`
### `mysql`
@@ -105,14 +106,14 @@ Key configuration:
## Environment Files
### `local.env.example`
### `.env.example`
Use this file as a starting point for the public compose stack.
Important values:
- `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
- `DB_*` - MySQL credentials and database name for the stack
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises
@@ -121,40 +122,54 @@ Important values:
- `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
### `remote.env.example`
### `.env.remote.example`
Use this file on a remote player device.
Important values:
- `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_PUBLIC_BASE_URL` - public URL for the remote player
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
- `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
| Variable | Used By | Purpose |
| --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | all services | Docker image to run for the app services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge | Shared secret for authenticated requests between services. |
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app 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_PORT` | web, player, bridge | Database port. |
| `DB_NAME` | web, player, bridge | Database name. |
| `DB_USER` | web, player, bridge | Database user. |
| `DB_PASSWORD` | web, player, bridge | Database password. |
| `DB_NAME` | web, player, bridge, mysql | Database name. |
| `DB_USER` | web, player, bridge, mysql | Database user. |
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
| `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. |
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `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. |
## Ports
@@ -191,14 +206,15 @@ Each compose file creates its own named network:
## Notes
- 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 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.
## Recommended Setup
1. Copy `local.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.
1. Copy `.env.example` to a local `.env` file for the public stack.
2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player.
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.
5. Verify that the player appears in Connected clients before testing screen commands.
@@ -9,8 +9,8 @@ services:
environment:
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
@@ -39,8 +39,8 @@ services:
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes:
- pulse-signage:/app/media
@@ -57,11 +57,12 @@ services:
ports:
- "8090:8090"
environment:
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
command: ["node", "src/player-bridge/index.js"]
depends_on:
@@ -76,8 +77,8 @@ services:
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: ${DB_NAME:-signage}
MYSQL_USER: ${DB_USER:-signage_user}
MYSQL_DATABASE: ${DB_NAME:-pulse-signage}
MYSQL_USER: ${DB_USER:-pulse-signage}
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
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) {
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 host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
if (!host) {
@@ -40,7 +69,12 @@ function resolveWebBaseUrl(req) {
if (url.port === '8090') {
url.port = '8080';
} 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(/\/$/, '');
@@ -600,6 +634,7 @@ async function start() {
});
playerSockets.set(deviceId, socket);
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`);
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
return;
}
@@ -646,11 +681,14 @@ async function start() {
}
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();
return;
}
playersWs.handleUpgrade(request, socket, head, function (ws) {
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
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) {
start().catch(function (error) {
+12 -6
View File
@@ -87,6 +87,8 @@ async function start() {
}
}
let webMediaSyncCompleted = false;
async function handleThinClientCommand(socket, rawMessage) {
let payload = null;
try {
@@ -246,11 +248,14 @@ async function start() {
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
}).catch(function () {
webMediaSyncTriggered = false;
});
if (!webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
@@ -263,9 +268,10 @@ async function start() {
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncTriggered) {
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
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) {
app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
schedulePostRenderSetup(app, 0);
return app.firstElementChild;
}
@@ -325,11 +338,7 @@ function renderSlideMarkup(markup, shouldFade) {
app.innerHTML = '';
nextShell.style.opacity = '1';
app.appendChild(nextShell);
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
return nextShell;
}
@@ -340,24 +349,12 @@ function renderSlideMarkup(markup, shouldFade) {
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell);
void nextShell.offsetHeight;
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(nextShell, 'intro');
}
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
});
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
@@ -54,6 +54,9 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
index = currentIndex;
var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
sendCommandState(slide);
if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
@@ -90,6 +93,9 @@ function showCurrent() {
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(activeSlides, index);
}
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(activeSlides, index);
}
if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
@@ -211,6 +217,9 @@ function refresh() {
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(nextActiveSlides, index);
}
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(nextActiveSlides, index);
}
if (nextActiveSlides.length < 2) {
pendingPlaylistUpdate = {
slides: nextSlides,
+58 -6
View File
@@ -84,7 +84,7 @@ function getCurrentRenderKey(activeSlides) {
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) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
@@ -97,19 +97,71 @@ function getWebpagePreloadSlides(sourceSlides, targetIndex) {
}
const preloadSlides = [];
const currentSlide = availableSlides[normalizedIndex];
const nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) {
preloadSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
if (nextSlide) {
preloadSlides.push(nextSlide);
}
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.
function syncWebpagePreloads(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('|');
}
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() {
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
@@ -919,9 +958,12 @@ function setCachedSlideMarkup(slide, markup) {
// Slide rendering and markup cache helpers.
// Choose the right slide renderer and cache the result.
function buildSlideMarkup(slide) {
lastRenderedSlide = slide || null;
syncBlackoutState();
function buildSlideMarkupForState(slide, updateCurrentState) {
if (updateCurrentState) {
lastRenderedSlide = slide || null;
syncBlackoutState();
}
var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) {
return cachedMarkup;
@@ -938,3 +980,7 @@ function buildSlideMarkup(slide) {
setCachedSlideMarkup(slide, 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 currentSlide = availableSlides[normalizedIndex];
var nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) {
warmupSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
if (nextSlide) {
warmupSlides.push(nextSlide);
}
@@ -693,12 +689,11 @@ function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, plac
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 20,
liveSyncDurationCount: 6,
liveMaxLatencyDurationCount: 12,
maxBufferLength: 30,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30
backBufferLength: 60
});
video.__rtmpHls = hls;
hls.attachMedia(video);
+3 -3
View File
@@ -122,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
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);
@@ -132,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) {
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);
@@ -141,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== 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 '';
@@ -18,50 +18,57 @@ function registerFontSweepTask(options) {
const uploadSyncService = options && options.uploadSyncService;
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
}
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
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);
const metadataPromise = typeof getPlayerTaskMetadata === 'function'
? Promise.resolve(getPlayerTaskMetadata())
: Promise.resolve({});
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
const uploadPath = String(operation.uploadPath || '').trim();
if (!uploadPath) {
continue;
return metadataPromise.then(function (metadata) {
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
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);
} 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) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
}
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial font sync',
category: TASK.category,
taskType: 'font-sync',
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: collectFontLibrarySyncOperations(mediaDir)
},
persist: true
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial font sync',
category: TASK.category,
taskType: 'font-sync',
metadata: Object.assign({}, metadata || {}),
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: collectFontLibrarySyncOperations(mediaDir)
},
persist: true
});
}).catch(function (error) {
console.warn('Unable to queue initial font sync:', error);
});
@@ -6,21 +6,29 @@ const TASK = {
function registerInitialMediaSyncTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
}
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial media sync',
category: TASK.category,
taskType: 'media-sync',
payload: {
mode: 'initial',
uploadDir: mediaDir
},
persist: true
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial media sync',
category: TASK.category,
taskType: 'media-sync',
metadata: Object.assign({}, metadata || {}),
payload: {
mode: 'initial',
uploadDir: mediaDir
},
persist: true
});
}).catch(function (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 { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) {
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) {
const pool = options && options.pool;
const common = options && options.common;
@@ -21,54 +49,120 @@ function createUploadSyncService(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const MAX_FIELD_BYTES = 10 * 1024 * 1024;
const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000;
const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null;
let pendingPlayerUploadSyncRetryLogAt = 0;
const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null;
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
}
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
const metadata = await getPlayerTaskMetadata();
return metadata && metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
}
async function getPlayerTaskMetadata() {
if (playerTaskMetadata) {
return playerTaskMetadata;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise;
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
playerTaskMetadataPromise = (async function () {
try {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier);
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
if (resolvedBaseUrl) {
playerInternalBaseUrl = resolvedBaseUrl;
return resolvedBaseUrl;
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const exactPlayer = Array.isArray(players)
? players.find(function (player) {
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) {
}
return configuredPlayerInternalBaseUrl || null;
})().then(function (baseUrl) {
playerInternalBaseUrlPromise = null;
return baseUrl || null;
playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: playerInternalBaseUrl,
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null
};
return playerTaskMetadata;
})().then(function (metadata) {
playerTaskMetadataPromise = null;
return metadata || null;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
playerTaskMetadataPromise = 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) {
@@ -561,6 +655,10 @@ function createUploadSyncService(options) {
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 () {
pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) {
@@ -582,6 +680,11 @@ function createUploadSyncService(options) {
pendingPlayerUploadSyncFlushInFlight = (async function () {
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) {
const operation = pendingEntries[i];
let success = false;
@@ -591,9 +694,25 @@ function createUploadSyncService(options) {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
}
if (success) {
successCount += 1;
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 () {
pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) {
@@ -689,12 +808,14 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const playerMetadata = await getPlayerTaskMetadata();
const definition = {
key: taskKey,
title: title,
category: 'media-sync',
taskType: 'media-sync',
metadata: Object.assign({}, playerMetadata || {}),
payload: safePayload,
persist: true
};
@@ -732,7 +853,8 @@ function createUploadSyncService(options) {
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask
queueMediaSyncTask: queueMediaSyncTask,
getPlayerTaskMetadata: getPlayerTaskMetadata
};
}
+49 -16
View File
@@ -1,5 +1,29 @@
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) {
const pool = options && options.pool;
@@ -14,10 +38,6 @@ function createPlayerActionService(options) {
let playerInternalBaseUrlPromise = null;
async function getPlayerInternalBaseUrl() {
if (configuredPlayerInternalBaseUrl) {
return configuredPlayerInternalBaseUrl;
}
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
}
@@ -27,22 +47,35 @@ function createPlayerActionService(options) {
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
try {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier);
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
if (resolvedBaseUrl) {
playerInternalBaseUrl = resolvedBaseUrl;
return resolvedBaseUrl;
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const exactPlayer = Array.isArray(players)
? players.find(function (player) {
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) {
}
return configuredPlayerInternalBaseUrl || null;
if (configuredPlayerInternalBaseUrl) {
playerInternalBaseUrl = configuredPlayerInternalBaseUrl;
return configuredPlayerInternalBaseUrl;
}
return null;
})().then(function (baseUrl) {
playerInternalBaseUrlPromise = null;
return baseUrl || null;
+38 -15
View File
@@ -7,6 +7,8 @@
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var latestDashboardState = null;
var ALL_SCREENS_SLUG = '__all__';
var ALL_SCREENS_LABEL = 'All screens';
function getClientMoveModalElements() {
return {
@@ -507,13 +509,22 @@
}
select.disabled = false;
if (!select.value || !screenBySlug[select.value]) {
select.value = screens[0].slug || '';
}
var selectedScreen = screenBySlug[select.value] || screens[0];
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
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) {
if (isAllSelected) {
return true;
}
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
@@ -532,12 +543,20 @@
pill.textContent = connectionLabel;
}
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) {
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) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
@@ -550,10 +569,12 @@
pauseStateInput.value = allPaused ? 'false' : 'true';
}
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');
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') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
@@ -561,23 +582,25 @@
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
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');
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 {
commandInput.value = command || commandInput.value || '';
}
}
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
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) {
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) {
control.disabled = !selectedSlug;
control.disabled = !commandTargetSlug;
});
});
}
+49
View File
@@ -11,6 +11,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission;
const ALL_SCREENS_SLUG = '__all__';
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
@@ -28,6 +29,46 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
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]);
if (!screenRows.length) {
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 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(
(liveConnection && liveConnection.playerPublicBaseUrl) ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
@@ -143,6 +143,30 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
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) {
const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -171,7 +195,10 @@ function buildQueuePageViewModel(data, message, currentUser) {
'finishedAt',
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.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);
});
const sortedTasks = sortRows(filteredTasks, function (task) {
@@ -194,7 +221,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
return task && task.finishedAt;
}
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();
}).join(' ');
}
@@ -234,11 +261,16 @@ function buildQueuePageViewModel(data, message, currentUser) {
});
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, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).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);
@@ -287,7 +319,10 @@ function buildScheduledPageViewModel(data, message, currentUser) {
'lastError',
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.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);
});
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
@@ -309,7 +344,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
}).join(' ');
}
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();
}).join(' ');
}
@@ -325,7 +360,8 @@ function buildScheduledPageViewModel(data, message, currentUser) {
backgroundTasksMenuOpen: true,
recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
intervalLabel: formatIntervalLabel(task.intervalMs),
playerLabel: buildTaskPlayerLabel(task.metadata)
});
}),
summary: summary,
@@ -65,8 +65,8 @@
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
{{#if key}}
<div class="text-muted small text-break">{{key}}</div>
{{#if taskDescription}}
<div class="text-muted small text-break">{{taskDescription}}</div>
{{/if}}
{{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
@@ -34,6 +34,9 @@
<tr data-table-search-row>
<td data-label="Task">
<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>
</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-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Screen-level controls</h3>
<h3 class="card-title">Screen Group controls</h3>
</div>
</div>
<div class="card-body">
@@ -26,6 +26,7 @@
aria-label="Target screen group"
data-screen-command-select
>
<option value="" selected disabled>Select Screen Group</option>
{{#each screens}}
<option
value="{{slug}}"
@@ -34,6 +35,7 @@
data-playlist-name="{{playlist_name}}"
>{{name}}</option>
{{/each}}
<option value="__all__" data-screen-name="All screens" data-screen-target-all="true">All Screens</option>
</select>
</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',
createdAt: '2026-08-04T10:15: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',
@@ -53,7 +56,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
key: 'refresh',
intervalMs: 60000,
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 }
@@ -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-local-datetime-format="24h"/);
assert.match(queueHtml, /Player Alpha:secret-key/);
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
assert.match(queueDateSearchHtml, /Example task/);
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));
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.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');
});