Refine player control-plane flow
This commit is contained in:
@@ -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"
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(','), '');
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user