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