Release 2.6.7

This commit is contained in:
2026-08-08 13:02:31 +01:00
parent b20beb250b
commit 7e46fffc34
41 changed files with 2489 additions and 1290 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to this project will be documented in this file.
## 2.6.7 - 2026-08-08
### Fixed
- Admin client commands now stay on the bridge for remote players, so screen control no longer depends on a public player address.
- The connected-clients screen-group controls now use the same async bulk-command path as the dashboard, including the All Screens option and live pause/blackout toggles.
## 2.6.6 - 2026-08-07
### Fixed
+5 -5
View File
@@ -12,15 +12,15 @@ 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"
PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_INTERNAL_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
DEFAULT_ADMIN_PASSWORD="password123"
# Bridge settings for the player-bridge service
WEB_BASE_URL="http://web:8080"
WEB_INTERNAL_URL="http://web:8080"
BRIDGE_INTERNAL_URL="http://player-bridge:8090"
+2 -2
View File
@@ -4,8 +4,8 @@ PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings
PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote player connectivity settings
THIN_CLIENT_BASE_URL="http://player-agent:8090"
BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
+22 -13
View File
@@ -59,15 +59,16 @@ Responsibilities:
- serves the player UI on port `8081`
- connects to MySQL in local mode
- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL`
- connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL`
- registers live connections and accepts control commands
Key configuration:
- `PLAYER_PUBLIC_BASE_URL`
- `PLAYER_INTERNAL_BASE_URL`
- `PLAYER_PUBLIC_URL`
- `PLAYER_INTERNAL_URL`
- `BRIDGE_INTERNAL_URL`
- `PLAYER_IDENTIFIER`
- `THIN_CLIENT_BASE_URL` in remote mode
- `BRIDGE_PUBLIC_URL` in remote mode
- `PULSE_SIGNAGE_SHARED_SECRET`
- database settings in local mode
@@ -85,7 +86,7 @@ Responsibilities:
Key configuration:
- `PULSE_SIGNAGE_SHARED_SECRET`
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers
- `WEB_INTERNAL_URL` for the bridge when it should call the web app directly instead of inferring from request headers
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
### `mysql`
@@ -116,11 +117,14 @@ Important values:
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises
- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls
- `PLAYER_PUBLIC_URL` - public URL the player advertises
- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls
- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding
- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
- `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
### `.env.remote.example`
@@ -131,8 +135,8 @@ Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
- `PLAYER_PUBLIC_URL` - public URL for the remote player
- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
### `PULSE_SIGNAGE_SHARED_SECRET`
@@ -166,11 +170,16 @@ Leave it blank only if you intentionally want to run without request signing in
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. |
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. |
| `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. |
| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. |
| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. |
| `BRIDGE_PUBLIC_URL` | player, remote player | URL of the bridge service. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
| `MYSQL_DATABASE` | mysql | Database name used by the local MySQL container. |
| `MYSQL_USER` | mysql | Database user used by the local MySQL container. |
| `MYSQL_PASSWORD` | mysql | Database password used by the local MySQL container. |
## Ports
@@ -208,7 +217,7 @@ Each compose file creates its own named network:
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint.
- The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases.
## Recommended Setup
+2 -2
View File
@@ -10,8 +10,8 @@ services:
ports:
- "8081:8081"
environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-}
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
volumes:
- pulse-signage:/app/media
+5 -5
View File
@@ -15,11 +15,11 @@ services:
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
volumes:
- pulse-signage:/app/media
command: ["node", "src/web.js"]
@@ -35,8 +35,8 @@ services:
ports:
- "8081:8081"
environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081}
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
@@ -59,7 +59,7 @@ services:
ports:
- "8090:8090"
environment:
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.6.6",
"version": "2.6.7",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+238 -58
View File
@@ -44,6 +44,55 @@ function formatPlayerConnectionLabel(deviceId, remoteAddress) {
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
}
function normalizeProxyBaseUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return '';
}
try {
const url = new URL(normalized);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
url.hostname = 'host.docker.internal';
}
return url.toString().replace(/\/$/, '');
} catch (_error) {
return normalized;
}
}
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 === 'player-dev'
|| host === 'player-local'
|| host === 'player-bridge-dev'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function resolveSnapshotUpstreamBaseUrl(player) {
const internalBaseUrl = normalizeProxyBaseUrl(player && player.internal_base_url);
if (internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl)) {
return internalBaseUrl;
}
return normalizeProxyBaseUrl(player && player.public_base_url) || null;
}
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
const key = String(slug || '').trim();
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
@@ -51,24 +100,38 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds)
}
const deviceIds = screenPlayerDeviceIds.get(key);
if (!Array.isArray(deviceIds) || !deviceIds.length) {
return [];
const targets = Array.isArray(deviceIds)
? Array.from(new Set(deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean))).map(function (deviceId) {
const socket = playerSockets.get(deviceId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
return null;
}
return { deviceId: deviceId, socket: socket };
}).filter(Boolean)
: [];
if (targets.length) {
return targets;
}
return Array.from(new Set(deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean))).map(function (deviceId) {
const socket = playerSockets.get(deviceId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
return null;
}
const fallbackTargets = Array.from(playerSockets.values()).filter(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
}).map(function (socket) {
return {
deviceId: String(socket.playerDeviceId || '').trim(),
socket: socket
};
}).filter(function (target) {
return Boolean(target.deviceId);
});
return { deviceId: deviceId, socket: socket };
}).filter(Boolean);
return fallbackTargets.length === 1 ? fallbackTargets : [];
}
function resolveWebBaseUrl(req) {
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
if (configuredWebBaseUrl) {
return configuredWebBaseUrl;
}
@@ -118,24 +181,130 @@ async function start() {
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
const playerSockets = new Map();
const screenSnapshotCache = new Map();
const screenSnapshotSourcesBySlug = new Map();
const screenSnapshotSubscribersBySlug = new Map();
const screenPlayerDeviceIds = new Map();
const pendingPlayerCommands = new Map();
function normalizeProxyBaseUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return '';
function getScreenSnapshotSourceBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
try {
const url = new URL(normalized);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
url.hostname = 'host.docker.internal';
}
return url.toString().replace(/\/$/, '');
} catch (_error) {
return normalized;
if (!screenSnapshotSourcesBySlug.has(key)) {
screenSnapshotSourcesBySlug.set(key, new Map());
}
return screenSnapshotSourcesBySlug.get(key);
}
function getScreenSnapshotSubscriberBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSubscribersBySlug.has(key)) {
screenSnapshotSubscribersBySlug.set(key, new Set());
}
return screenSnapshotSubscribersBySlug.get(key);
}
function buildMergedScreenSnapshot(slug) {
const key = String(slug || '').trim();
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
const connections = [];
const deviceIds = [];
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
sourceBucket.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
sourceBucket.forEach(function (_payload, sourceKey) {
deviceIds.push(sourceKey);
});
}
return {
slug: key,
count: connections.length,
connections: connections,
deviceIds: deviceIds
};
}
function broadcastScreenSnapshot(slug) {
const key = String(slug || '').trim();
const snapshot = buildMergedScreenSnapshot(key);
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
const bucket = screenSnapshotSubscribersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshot.connections,
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
function setScreenSnapshotSource(slug, sourceKey, connections) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
if (!key || !normalizedSourceKey) {
return;
}
const bucket = getScreenSnapshotSourceBucket(key);
if (!bucket) {
return;
}
bucket.set(normalizedSourceKey, {
slug: key,
connections: Array.isArray(connections) ? connections : []
});
broadcastScreenSnapshot(key);
}
function clearScreenSnapshotSource(slug, sourceKey) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
const bucket = screenSnapshotSourcesBySlug.get(key);
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
return;
}
bucket.delete(normalizedSourceKey);
if (!bucket.size) {
screenSnapshotSourcesBySlug.delete(key);
}
broadcastScreenSnapshot(key);
}
function clearPlayerSnapshotSources(sourceKey) {
const normalizedSourceKey = String(sourceKey || '').trim();
if (!normalizedSourceKey) {
return;
}
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
clearScreenSnapshotSource(slug, normalizedSourceKey);
});
}
async function fetchPlayerSnapshotRegistrations() {
@@ -641,6 +810,16 @@ async function start() {
socket.playerDeviceId = deviceId;
if (messageType === 'snapshot') {
const slug = String(payload.slug || '').trim();
if (!slug) {
return;
}
setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []);
return;
}
if (messageType === 'register') {
const player = await upsertPlayerRegistration(pool, {
deviceId: deviceId,
@@ -719,12 +898,14 @@ async function start() {
});
socket.on('close', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
});
socket.on('error', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
@@ -734,33 +915,18 @@ async function start() {
screenSnapshotsWs.on('connection', function (socket, request, slug) {
const normalizedSlug = String(slug || '').trim();
const upstreamSockets = new Map();
const upstreamSnapshots = new Map();
const upstreamDeviceIds = new Set();
let refreshTimer = null;
let closed = false;
function sendMergedSnapshot() {
if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) {
return;
}
const connections = [];
upstreamSnapshots.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds));
socket.send(JSON.stringify({
type: 'snapshot',
slug: normalizedSlug,
connections: connections,
sentAt: new Date().toISOString()
}));
const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug);
if (!subscriberBucket) {
socket.close();
return;
}
subscriberBucket.add(socket);
function closeUpstreamSockets() {
upstreamSockets.forEach(function (upstreamSocket) {
try {
@@ -769,7 +935,6 @@ async function start() {
}
});
upstreamSockets.clear();
upstreamSnapshots.clear();
}
async function refreshUpstreams() {
@@ -786,12 +951,19 @@ async function start() {
const seenKeys = new Set();
players.forEach(function (player) {
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
const sourceKey = String(player && player.identifier || player && player.id || '').trim();
const connectedSocket = sourceKey ? playerSockets.get(sourceKey) : null;
if (connectedSocket && connectedSocket.readyState === WebSocket.OPEN) {
seenKeys.add(sourceKey);
upstreamDeviceIds.add(sourceKey);
return;
}
const baseUrl = resolveSnapshotUpstreamBaseUrl(player);
if (!baseUrl) {
return;
}
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
seenKeys.add(sourceKey);
upstreamDeviceIds.add(sourceKey);
if (upstreamSockets.has(sourceKey)) {
@@ -814,20 +986,15 @@ async function start() {
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
return;
}
upstreamSnapshots.set(sourceKey, {
slug: normalizedSlug,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
sendMergedSnapshot();
setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []);
} catch (_error) {
}
};
upstreamSocket.onclose = function () {
upstreamSockets.delete(sourceKey);
upstreamSnapshots.delete(sourceKey);
if (!closed) {
sendMergedSnapshot();
if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) {
clearScreenSnapshotSource(normalizedSlug, sourceKey);
}
};
@@ -852,7 +1019,7 @@ async function start() {
}
});
sendMergedSnapshot();
broadcastScreenSnapshot(normalizedSlug);
}
refreshUpstreams();
@@ -863,6 +1030,10 @@ async function start() {
socket.on('close', function () {
closed = true;
subscriberBucket.delete(socket);
if (!subscriberBucket.size) {
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
}
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
@@ -872,6 +1043,10 @@ async function start() {
socket.on('error', function () {
closed = true;
subscriberBucket.delete(socket);
if (!subscriberBucket.size) {
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
}
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
@@ -915,7 +1090,12 @@ async function start() {
});
}
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets };
module.exports = {
start: start,
resolveWebBaseUrl: resolveWebBaseUrl,
resolveScreenCommandTargets: resolveScreenCommandTargets,
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl
};
if (require.main === module) {
start().catch(function (error) {
+64 -26
View File
@@ -18,21 +18,37 @@ const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/d
// Player runtime, media API, and websocket wiring.
async function start() {
const app = express();
const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL);
const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
let thinClientSocket = null;
const playerRuntime = createPlayerRuntime({
pool: pool,
normalizeDeviceId: normalizeDeviceId
normalizeDeviceId: normalizeDeviceId,
notifySnapshot: function (snapshot) {
if (!thinClientSocket || thinClientSocket.readyState !== WebSocket.OPEN) {
return;
}
try {
thinClientSocket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
}));
} catch (_error) {
}
}
});
const playerPlaylistService = isRemotePlayer
? null
@@ -59,9 +75,9 @@ async function start() {
console.info('[player] startup', {
mode: isRemotePlayer ? 'bridge client' : 'local',
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null,
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null,
bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null
publicBaseUrl: PLAYER_PUBLIC_URL || null,
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
});
}
@@ -83,7 +99,7 @@ async function start() {
}
async function triggerWebMediaSync() {
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) {
if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) {
return false;
}
@@ -92,7 +108,7 @@ async function start() {
method: 'POST',
pathname: '/api/internal/sync/player-media'
});
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, {
const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
@@ -194,9 +210,9 @@ async function start() {
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
registerPlayerRoutes(app, {
@@ -207,18 +223,18 @@ async function start() {
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
function createThinClientWebSocketUrl() {
if (!THIN_CLIENT_BASE_URL) {
if (!BRIDGE_PUBLIC_URL) {
return null;
}
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
}
function startThinClientRegistration() {
@@ -256,6 +272,23 @@ async function start() {
'x-pulse-request-timestamp': timestamp
}, authHeaders)
});
thinClientSocket = socket;
function sendSnapshot(slug) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
try {
socket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
slug: String(slug || '').trim(),
connections: playerRuntime.snapshotConnections(slug)
}));
} catch (_error) {
}
}
socket.on('open', function () {
logPlayerStartup({
@@ -265,10 +298,14 @@ async function start() {
socket.send(JSON.stringify({
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
publicBaseUrl: PLAYER_PUBLIC_URL,
internalBaseUrl: PLAYER_INTERNAL_URL
}));
playerRuntime.snapshotSlugs().forEach(function (slug) {
sendSnapshot(slug);
});
if (!webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
@@ -285,8 +322,8 @@ async function start() {
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
publicBaseUrl: PLAYER_PUBLIC_URL,
internalBaseUrl: PLAYER_INTERNAL_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
@@ -317,6 +354,7 @@ async function start() {
socket.on('close', function () {
clearTimers();
thinClientSocket = null;
reconnectTimer = setTimeout(connect, 5000);
});
@@ -363,8 +401,8 @@ async function start() {
try {
await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
publicBaseUrl: PLAYER_PUBLIC_URL,
internalBaseUrl: PLAYER_INTERNAL_URL
}).catch(function (error) {
console.error(error);
});
@@ -392,7 +430,7 @@ async function start() {
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) {
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error);
});
}
+17 -17
View File
@@ -16,7 +16,7 @@ function normalizeDeviceId(value) {
}
function getPublicBaseUrl(req, configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
@@ -50,7 +50,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) {
}
function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
@@ -203,22 +203,22 @@ function registerPlayerOnboardingRoutes(app, options) {
const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
}
if (!thinClientBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.');
if (!bridgeBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) {
if (!bridgeBaseUrl) {
return null;
}
@@ -239,7 +239,7 @@ function registerPlayerOnboardingRoutes(app, options) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
@@ -305,7 +305,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (thinClientBaseUrl) {
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
});
@@ -317,7 +317,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
return res.json(payload);
}
@@ -329,7 +329,7 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
@@ -338,7 +338,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (thinClientBaseUrl) {
if (bridgeBaseUrl) {
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
method: 'GET'
});
@@ -361,7 +361,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
@@ -382,11 +382,11 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
if (thinClientBaseUrl) {
if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
});
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), {
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
method: 'POST',
headers: Object.assign({
'content-type': 'application/json'
@@ -402,7 +402,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`;
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
if (!deviceId) {
@@ -423,7 +423,7 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
@@ -7,6 +7,12 @@
var form = document.getElementById("onboarding-form");
var message = document.getElementById("onboarding-message");
var screenSelect = document.getElementById("onboarding-screen-select");
function getSessionStorageItem(key) {
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
}
function setSessionStorageItem(key, value) {
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
}
function setMessage(value) { if (message) { message.textContent = value || ""; } }
function parseResponseError(response) {
return response.text().then(function (text) {
@@ -41,15 +47,15 @@
}
try {
if (!deviceId) {
deviceId = window.localStorage.getItem(deviceKey) || "";
deviceId = window.sessionStorage.getItem(deviceKey) || "";
}
if (deviceId) {
window.localStorage.setItem(deviceKey, deviceId);
window.sessionStorage.setItem(deviceKey, deviceId);
}
} catch (_error) {}
loadScreens().then(function () {
try {
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
var storedClientName = getSessionStorageItem(clientNameKey) || "";
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
@@ -83,7 +89,7 @@
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setMessage("Onboarding complete.");
if (form) {
@@ -2,6 +2,12 @@
(function () {
var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name";
function getSessionStorageItem(key) {
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
}
function setSessionStorageItem(key, value) {
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
}
function getClientNameStorageKey(_screenSlug) {
return clientNameKey;
}
@@ -25,10 +31,10 @@
}
function getDeviceId() {
var stored = "";
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function setStatus(message) { if (status) { status.textContent = message; } }
@@ -83,8 +89,8 @@
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete.");
if (localForm) {
@@ -99,8 +105,8 @@
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
return true;
@@ -127,8 +133,8 @@
loadScreens().then(function () {
try {
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
var storedClientName = getSessionStorageItem(clientNameKey) || "";
if (!storedClientName && storedScreenSlug) { storedClientName = getSessionStorageItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput) { clientNameInput.value = storedClientName; }
+21 -19
View File
@@ -4,9 +4,25 @@
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
function getSessionStorageItem(key) {
try {
return window.sessionStorage.getItem(key) || '';
} catch (_error) {
return '';
}
}
function setSessionStorageItem(key, value) {
try {
window.sessionStorage.setItem(key, value);
} catch (_error) {
// ignore storage errors
}
}
function getOnboardingDeviceId() {
try {
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
return String(storedDeviceId || '').trim();
} catch (_error) {
return '';
@@ -19,24 +35,14 @@
return onboardingClientName;
}
try {
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey);
if (storedClientName) {
onboardingClientName = storedClientName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
} catch (_mirrorError) {
// ignore storage errors
}
return onboardingClientName;
}
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
var genericClientName = getSessionStorageItem('pulse-signage-player-client-name');
if (genericClientName) {
onboardingClientName = genericClientName;
try {
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
} catch (_error) {
// ignore storage errors
}
return onboardingClientName;
}
} catch (_error) {
@@ -51,12 +57,8 @@
return;
}
onboardingClientName = normalizedName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
} catch (_error) {
// ignore storage errors
}
setSessionStorageItem('pulse-signage-player-client-name', normalizedName);
setSessionStorageItem(onboardingClientNameStorageKey, normalizedName);
if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandState(socket);
}
+3 -3
View File
@@ -188,13 +188,13 @@ function syncWebpagePreloads(sourceSlides, targetIndex) {
preloadSignature = signature;
}
// Return a stable client id for this browser session.
// Return a stable client id for this screen session.
function getCommandClientId() {
if (commandClientId) {
return commandClientId;
}
try {
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
var storedClientId = window.sessionStorage.getItem(commandClientStorageKey);
if (storedClientId) {
commandClientId = storedClientId;
return commandClientId;
@@ -204,7 +204,7 @@ function getCommandClientId() {
}
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
try {
window.localStorage.setItem(commandClientStorageKey, commandClientId);
window.sessionStorage.setItem(commandClientStorageKey, commandClientId);
} catch (_error2) {
// ignore storage errors
}
+18 -18
View File
@@ -31,23 +31,23 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
}
if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) {
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.');
if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) {
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) {
async function fetchBridge(req, pathname, options) {
if (!bridgeBaseUrl) {
return null;
}
@@ -71,7 +71,7 @@ function registerPlayerRoutes(app, options) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
@@ -179,8 +179,8 @@ function registerPlayerRoutes(app, options) {
});
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (thinClientBaseUrl) {
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), {
if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
@@ -298,9 +298,9 @@ function registerPlayerRoutes(app, options) {
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
if (thinClientBaseUrl) {
if (bridgeBaseUrl) {
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
method: 'GET',
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) {
@@ -342,8 +342,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
method: 'GET'
});
if (!response) {
@@ -397,8 +397,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET'
});
if (!response) {
@@ -440,8 +440,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET'
});
if (!response) {
+17 -1
View File
@@ -21,6 +21,7 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
? options.normalizeDeviceId
: function (value) {
@@ -222,6 +223,10 @@ function createPlayerRuntime(options) {
return allConnections;
}
function snapshotSlugs() {
return Array.from(connectionsBySlug.keys());
}
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
}
@@ -229,6 +234,16 @@ function createPlayerRuntime(options) {
function broadcastConnectionSnapshot(slug) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
const connections = snapshotConnections(slug);
if (notifySnapshot) {
try {
notifySnapshot({
slug: key,
connections: connections
});
} catch (_error) {
}
}
if (!bucket || !bucket.size) {
return;
}
@@ -236,7 +251,7 @@ function createPlayerRuntime(options) {
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshotConnections(slug),
connections: connections,
sentAt: new Date().toISOString()
});
@@ -508,6 +523,7 @@ function createPlayerRuntime(options) {
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections,
snapshotSlugs: snapshotSlugs,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
sendCommandToConnection: sendCommandToConnection,
broadcastCommand: broadcastCommand
+5 -4
View File
@@ -62,7 +62,8 @@ async function start() {
const playerActionService = createPlayerActionService({
pool: pool,
common: common,
playerInternalBaseUrl: webConfig.thinClientBaseUrl
playerInternalBaseUrl: webConfig.playerInternalUrl,
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
});
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
@@ -70,8 +71,8 @@ async function start() {
const webBootstrap = createWebBootstrap({
pool: pool,
common: common,
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
thinClientBaseUrl: webConfig.thinClientBaseUrl,
playerInternalBaseUrl: webConfig.playerInternalUrl,
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl,
uploadDir: webConfig.uploadsDir,
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens,
@@ -210,7 +211,7 @@ async function start() {
initializeBackgroundTasks: initializeBackgroundTasks,
captureSlideThumbnail: captureSlideThumbnail,
server: server,
webBaseUrl: webConfig.webBaseUrl,
webBaseUrl: webConfig.webInternalUrl,
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
});
+6 -6
View File
@@ -8,8 +8,8 @@ const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) {
const pool = options && options.pool;
const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const configuredPlayerInternalUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalUrl = String(options && options.bridgeInternalBaseUrl || process.env.BRIDGE_INTERNAL_URL || '').trim().replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = 5000;
const formatDashboardDate = options && options.formatDashboardDate;
@@ -27,7 +27,7 @@ function createWebBootstrap(options) {
let dashboardRefreshInFlight = null;
let broadcastDashboardState = null;
function getPlayerSnapshotSocketUrl(slug) {
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl;
if (!resolvedPlayerInternalBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
@@ -109,7 +109,7 @@ function createWebBootstrap(options) {
const dashboardStateService = createDashboardStateService({
pool: pool,
common: common,
thinClientBaseUrl: configuredThinClientBaseUrl,
thinClientBaseUrl: configuredBridgeInternalUrl,
playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
@@ -120,7 +120,7 @@ function createWebBootstrap(options) {
const uploadSyncService = createUploadSyncService({
pool: pool,
common: common,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
playerInternalBaseUrl: configuredPlayerInternalUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
@@ -253,7 +253,7 @@ function createWebBootstrap(options) {
return {
upload: upload,
uploadSyncService: uploadSyncService,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
playerInternalBaseUrl: configuredPlayerInternalUrl || null,
buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
@@ -18,57 +18,50 @@ function registerFontSweepTask(options) {
const uploadSyncService = options && options.uploadSyncService;
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
}
const metadataPromise = typeof getPlayerTaskMetadata === 'function'
? Promise.resolve(getPlayerTaskMetadata())
: Promise.resolve({});
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {
mediaDir: mediaDir
},
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
return metadataPromise.then(function (metadata) {
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: Object.assign({
mediaDir: mediaDir
}, metadata || {}),
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
const uploadPath = String(operation.uploadPath || '').trim();
if (!uploadPath) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await removeUploadFileFromPlayer(uploadPath, mediaDir);
} else {
await pushUploadFileToPlayer(uploadPath, mediaDir);
}
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
const uploadPath = String(operation.uploadPath || '').trim();
if (!uploadPath) {
continue;
}
for (let i = 0; i < currentUploadPaths.length; i += 1) {
const uploadPath = String(currentUploadPaths[i] || '').trim();
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
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);
}
}
});
}
@@ -0,0 +1,32 @@
const TASK = {
key: 'onboarding-device-prune',
title: 'Onboarding device prune',
category: 'cleanup',
trigger: 'scheduled recurring task, hourly',
purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.',
taskType: 'recurring-run',
intervalMs: 60 * 60 * 1000
};
function registerOnboardingDevicePruneTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const pool = options && options.pool;
const common = options && options.common;
if (!backgroundTaskQueue || !pool || !common || typeof common.pruneStaleOnboardingDevices !== 'function') {
throw new Error('registerOnboardingDevicePruneTask requires the onboarding prune dependencies.');
}
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {},
run: async function () {
await common.pruneStaleOnboardingDevices(pool);
}
});
}
module.exports = { registerOnboardingDevicePruneTask };
+6 -6
View File
@@ -5,9 +5,9 @@ function createWebConfig() {
const uploadsDir = path.join(mediaDir, 'uploads');
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
const assetDir = path.join(__dirname, '..', 'public');
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, '');
const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
const sessionCookieName = 'digital_signage_session';
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
@@ -20,9 +20,9 @@ function createWebConfig() {
uploadsDir: uploadsDir,
thumbnailsDir: thumbnailsDir,
assetDir: assetDir,
playerInternalBaseUrl: playerInternalBaseUrl,
thinClientBaseUrl: thinClientBaseUrl,
webBaseUrl: webBaseUrl,
playerInternalUrl: playerInternalUrl,
bridgeInternalUrl: bridgeInternalUrl,
webInternalUrl: webInternalUrl,
sessionCookieName: sessionCookieName,
sessionMaxAgeMs: sessionMaxAgeMs,
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
+13
View File
@@ -438,6 +438,9 @@ function createUploadSyncService(options) {
console.warn('Unable to flush pending upload syncs:', error);
});
}, 5000);
if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') {
pendingPlayerUploadSyncFlushTimer.unref();
}
}
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
@@ -677,6 +680,11 @@ function createUploadSyncService(options) {
return pendingPlaylistUploadSyncFlushInFlight;
}
if (pendingPlaylistUploadSyncFlushTimer) {
clearTimeout(pendingPlaylistUploadSyncFlushTimer);
pendingPlaylistUploadSyncFlushTimer = null;
}
if (!pendingPlaylistUploadSyncs.size) {
return null;
}
@@ -715,6 +723,11 @@ function createUploadSyncService(options) {
return pendingPlayerUploadSyncFlushInFlight;
}
if (pendingPlayerUploadSyncFlushTimer) {
clearTimeout(pendingPlayerUploadSyncFlushTimer);
pendingPlayerUploadSyncFlushTimer = null;
}
if (!pendingPlayerUploadSyncs.size) {
return null;
}
+119 -66
View File
@@ -1,5 +1,5 @@
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
@@ -33,24 +33,10 @@ function isRecentPlayerRegistration(player, staleSeconds) {
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
async function fetchRecentPlayerRegistrations(pool) {
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await fetchPlayerRegistrations(pool);
return (Array.isArray(players) ? players : []).filter(function (player) {
return isRecentPlayerRegistration(player, 60);
});
} catch (_error) {
return [];
}
}
function createPlayerActionService(options) {
const pool = options && options.pool;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const common = options && options.common;
if (!common) {
@@ -151,6 +137,79 @@ function createPlayerActionService(options) {
});
}
async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
if (!targetBaseUrl) {
return null;
}
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
});
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return null;
});
}
async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) {
const targetDeviceId = String(deviceId || '').trim();
const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
if (!targetDeviceId) {
throw new Error('Device ID is required.');
}
if (!targetBridgeBaseUrl) {
throw new Error('Unable to resolve the player bridge base URL.');
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`,
body: payload
});
const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
@@ -190,69 +249,62 @@ function createPlayerActionService(options) {
}
async function getScreenConnections(slug) {
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
});
const recentPlayers = await fetchRecentPlayerRegistrations(pool);
const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) {
return normalizeBaseUrl(player && player.public_base_url);
}).filter(Boolean)));
if (!targetBaseUrls.length) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (resolvedPlayerInternalBaseUrl) {
targetBaseUrls.push(resolvedPlayerInternalBaseUrl);
}
}
const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
const playerBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean)));
if (!targetBaseUrls.length) {
throw new Error('Unable to resolve the player internal base URL.');
}
const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) {
const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
return null;
}
return response.json().catch(function () {
return null;
});
const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) {
return fetchScreenConnectionsFromBaseUrl(baseUrl, slug);
}));
const successfulResults = results.filter(function (result) {
return result.status === 'fulfilled' && result.value;
}).map(function (result) {
return result.value;
});
if (!successfulResults.length) {
const rejection = results.find(function (result) {
return result.status === 'rejected';
});
throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`);
}
const mergedConnections = [];
let screen = null;
let degraded = false;
results.forEach(function (result) {
if (!result) {
degraded = true;
return;
}
if (!screen && result.screen) {
screen = result.screen;
}
if (Array.isArray(result.connections)) {
mergedConnections.push.apply(mergedConnections, result.connections);
}
if (result.degraded) {
degraded = true;
}
const seenKeys = new Set();
successfulResults.forEach(function (result) {
const connections = Array.isArray(result && result.connections) ? result.connections : [];
connections.forEach(function (connection) {
const key = [
String(connection && connection.id || '').trim(),
String(connection && connection.clientId || '').trim(),
String(connection && connection.deviceId || '').trim(),
String(connection && connection.playerPublicBaseUrl || '').trim()
].join('|');
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
mergedConnections.push(connection);
});
});
return {
screen: screen,
screen: successfulResults.find(function (result) {
return Boolean(result && result.screen);
}) ? successfulResults.find(function (result) {
return Boolean(result && result.screen);
}).screen : null,
screenSlug: slug,
count: mergedConnections.length,
connections: mergedConnections,
degraded: degraded
degraded: results.some(function (result) {
return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded);
})
};
}
@@ -302,6 +354,7 @@ function createPlayerActionService(options) {
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: forwardPlayerCommandToDevice,
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
+333 -244
View File
@@ -8,8 +8,6 @@
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var LIST_PAGE_SIZE = 25;
var latestDashboardState = null;
var ALL_SCREENS_SLUG = '__all__';
var ALL_SCREENS_LABEL = 'All screens';
function getClientSearchInput() {
var table = document.getElementById('dashboard-clients-table');
@@ -20,6 +18,147 @@
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
}
function getScreenCommandSelect() {
return document.querySelector('[data-screen-command-select]');
}
function getScreenCommandForms() {
if (!document.querySelectorAll) {
return [];
}
return Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
}
function getSelectedScreenClients(state) {
var select = getScreenCommandSelect();
var selectedSlug = select ? String(select.value || '').trim() : '';
var clients = Array.isArray(state && state.clients) ? state.clients : [];
if (!selectedSlug || selectedSlug === '__all__') {
return clients;
}
return clients.filter(function (client) {
return String(client && client.screen_slug || '').trim() === selectedSlug;
});
}
function getSelectedScreenLabel() {
var select = getScreenCommandSelect();
if (!select) {
return '';
}
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
return selectedOption ? String(selectedOption.getAttribute('data-screen-name') || selectedOption.textContent || '').trim() : '';
}
function updateToggleButton(button, form, state) {
if (!button) {
return;
}
var action = String(form && form.getAttribute('data-screen-command-action') || '').trim();
var selectedLabel = getSelectedScreenLabel() || 'selected screen group';
var isAllScreens = String(getScreenCommandSelect() && getScreenCommandSelect().value || '').trim() === '__all__';
var clients = getSelectedScreenClients(state);
var hasClients = clients.length > 0;
var allPaused = hasClients && clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && clients.every(function (client) {
return Boolean(client && client.blackout);
});
if (action === 'pause') {
var pauseLabel = allPaused ? 'Resume ' + (isAllScreens ? 'all clients' : 'screen') : 'Pause ' + (isAllScreens ? 'all clients' : 'screen');
var pauseConfirm = allPaused ? 'Resume ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Pause ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
var pauseIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
button.innerHTML = '<i class="bi ' + pauseIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
button.setAttribute('aria-label', pauseLabel);
button.setAttribute('title', pauseLabel);
if (form) {
var pauseInput = form.querySelector('input[name="paused"]');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
form.setAttribute('data-confirm-message', pauseConfirm);
}
return;
}
if (action === 'blackout') {
var blackoutLabel = allBlackout ? 'Restore ' + (isAllScreens ? 'all clients' : 'screen') : 'Blackout ' + (isAllScreens ? 'all clients' : 'screen');
var blackoutConfirm = allBlackout ? 'Restore ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Blackout ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
var blackoutIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
button.innerHTML = '<i class="bi ' + blackoutIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(blackoutLabel);
setButtonVariant(button, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
button.setAttribute('aria-label', blackoutLabel);
button.setAttribute('title', blackoutLabel);
if (form) {
var blackoutInput = form.querySelector('input[name="blackout"]');
if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true';
}
form.setAttribute('data-confirm-message', blackoutConfirm);
}
}
}
function updateScreenCommandControls() {
var select = getScreenCommandSelect();
if (!select) {
return;
}
var selectedSlug = String(select.value || '').trim();
var actionTarget = '/clients/' + encodeURIComponent(selectedSlug || '__all__') + '/commands';
var selectedName = getSelectedScreenLabel();
var selectedClients = getSelectedScreenClients(latestDashboardState);
var allPaused = selectedClients.length > 0 && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = selectedClients.length > 0 && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
getScreenCommandForms().forEach(function (form) {
if (!form) {
return;
}
form.setAttribute('action', actionTarget);
var action = String(form.getAttribute('data-screen-command-action') || '').trim();
var button = form.querySelector('button[type="submit"]');
if (action === 'pause' || action === 'blackout') {
updateToggleButton(button, form, latestDashboardState);
return;
}
if (button) {
button.setAttribute('aria-label', selectedName ? selectedName : 'Selected screen group');
}
});
}
function initScreenCommandControls() {
var select = getScreenCommandSelect();
if (!select || (select.dataset && select.dataset.bound === 'true')) {
return;
}
if (select.dataset) {
select.dataset.bound = 'true';
}
select.addEventListener('change', updateScreenCommandControls);
updateScreenCommandControls();
}
function getClientListQueryState() {
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
var searchInput = getClientSearchInput();
@@ -170,77 +309,6 @@
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
}
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
}
function getClientMoveScreens() {
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
}
var select = document.getElementById('client-move-screen-target');
if (!select || !select.options) {
return [];
}
return Array.prototype.slice.call(select.options).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.textContent || option.value || '').trim()
};
}).filter(function (screen) {
return Boolean(screen && screen.slug);
});
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
option.disabled = false;
if (String(option.value || '').trim() === currentScreenSlug) {
option.disabled = true;
}
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
if (elements.playerBaseUrlInput) {
elements.playerBaseUrlInput.value = playerBaseUrl;
}
elements.targetSelect.value = '';
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info';
@@ -257,7 +325,7 @@
return [
'<div class="actions justify-content-end">',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
@@ -266,6 +334,60 @@
].join('');
}
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
if (!option) {
return;
}
option.disabled = String(option.value || '').trim() === currentScreenSlug;
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
if (elements.playerBaseUrlInput) {
elements.playerBaseUrlInput.value = playerBaseUrl;
}
if (elements.targetSelect) {
elements.targetSelect.value = '';
}
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function updateClientActionCell(cell, client) {
if (!cell) {
return;
@@ -440,7 +562,7 @@
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.id || '') + '" data-client-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>',
@@ -467,7 +589,8 @@
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client)));
row.setAttribute('data-client-id', escapeHtml(client.clientId || ''));
row.setAttribute('data-client-id', escapeHtml(client.id || ''));
row.setAttribute('data-client-client-id', escapeHtml(client.clientId || ''));
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
@@ -627,6 +750,112 @@
updateClientTable(latestDashboardState, true);
}
function initClientMoveHandler() {
var elements = getClientMoveModalElements();
if (!elements.modal || !elements.form || !elements.targetSelect) {
return;
}
if (typeof elements.modal.addEventListener === 'function') {
elements.modal.addEventListener('show.bs.modal', function (event) {
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
if (row) {
updateClientMoveModalFromRow(row);
}
});
}
document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) {
return;
}
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) {
return;
}
updateClientMoveModalFromRow(row);
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(elements.modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
}
});
elements.form.addEventListener('submit', function (event) {
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
if (!targetScreenSlug) {
event.preventDefault();
window.alert('Choose a target screen.');
return;
}
event.preventDefault();
elements.form.dataset.busy = 'true';
var formData = new FormData(elements.form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(elements.form.action, {
method: (elements.form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to move client.');
try {
var payload = JSON.parse(text);
if (payload && (payload.error || payload.message)) {
error = new Error(String(payload.error || payload.message));
}
} catch (_error) {
// fall back to the raw text body
}
throw error;
});
}
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(elements.modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
}
return response.json().catch(function () {
return { ok: true };
});
}).catch(function (error) {
window.alert(error && error.message ? error.message : 'Unable to move client.');
}).finally(function () {
delete elements.form.dataset.busy;
});
});
}
function updateKioskLauncherModal(state) {
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
if (!modal) {
@@ -696,163 +925,6 @@
}
grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateScreenCommandControls(state) {
var select = document.getElementById('screen-command-select');
if (!select) {
return;
}
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
var pill = document.querySelector('[data-screen-command-pill]');
var nameNode = document.querySelector('[data-screen-command-name]');
var metaNode = document.querySelector('[data-screen-command-meta]');
var screens = Array.isArray(state && state.screens) ? state.screens : [];
var screenBySlug = {};
screens.forEach(function (screen) {
if (screen && screen.slug) {
screenBySlug[String(screen.slug)] = screen;
}
});
if (!screens.length) {
select.value = '';
select.disabled = true;
forms.forEach(function (form) {
form.querySelectorAll('button, input').forEach(function (control) {
control.disabled = true;
});
});
if (pill) {
pill.classList.remove('is-live');
pill.classList.add('is-idle');
pill.textContent = 'No screens';
}
if (nameNode) {
nameNode.textContent = 'No target available';
}
if (metaNode) {
metaNode.textContent = 'Create a screen before using screen-level commands.';
}
return;
}
select.disabled = false;
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG;
var selectedScreen = isAllSelected
? {
slug: ALL_SCREENS_SLUG,
name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL
}
: screenBySlug[select.value] || null;
var selectedSlug = isAllSelected
? ALL_SCREENS_SLUG
: String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
if (isAllSelected) {
return true;
}
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
var hasClients = connectionCount > 0;
var allPaused = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
if (pill) {
pill.classList.toggle('is-live', hasClients);
pill.classList.toggle('is-idle', !hasClients);
pill.textContent = connectionLabel;
}
if (nameNode) {
nameNode.textContent = selectedScreen
? String(selectedScreen.name || 'Selected screen')
: 'Select a target screen group';
}
if (metaNode) {
metaNode.textContent = !selectedSlug
? 'Choose a screen group before sending commands.'
: isAllSelected
? 'Commands sent here target every client across every screen group.'
: 'Commands sent here target every client currently using this screen.';
}
var commandTargetSlug = selectedSlug || '';
forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
var button = form.querySelector('button[type="submit"]');
if (commandInput) {
if (command === 'pause') {
commandInput.value = allPaused ? 'pause' : 'pause';
var pauseStateInput = form.querySelector('input[name="paused"]');
if (pauseStateInput) {
pauseStateInput.value = allPaused ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen'));
}
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused
? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?')
: (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?'));
} else if (command === 'blackout') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen'));
}
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout
? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?')
: (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?'));
} else {
commandInput.value = command || commandInput.value || '';
}
}
form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
if (command === 'reload') {
form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?');
if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
}
}
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !commandTargetSlug;
});
});
}
function readScreenCommandStateFromDom() {
var select = document.getElementById('screen-command-select');
if (!select) {
return { screens: [] };
}
return {
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
};
})
};
}
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -889,7 +961,7 @@
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true';
}
@@ -907,10 +979,10 @@
window.webLatestDashboardState = latestDashboardState;
updateStats(state);
updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state);
updateKioskLauncherModal(state);
updateDashboardQuickActions(state);
updateScreenCommandControls();
}
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
@@ -967,8 +1039,8 @@
return;
}
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var clientId = String(row.getAttribute('data-client-id') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var currentName = String(cell.textContent || '').trim();
@@ -1003,18 +1075,41 @@
return;
}
if (typeof elements.modal.addEventListener === 'function') {
elements.modal.addEventListener('show.bs.modal', function (event) {
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
if (row) {
updateClientMoveModalFromRow(row);
}
});
}
document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) {
return;
}
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) {
return;
}
updateClientMoveModalFromRow(row);
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(elements.modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
}
});
elements.form.addEventListener('submit', function (event) {
@@ -1131,11 +1226,5 @@
initClientRenameHandler();
initClientMoveHandler();
initKioskLauncherModal();
var screenCommandSelect = document.getElementById('screen-command-select');
if (screenCommandSelect) {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
screenCommandSelect.addEventListener('change', function () {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
});
}
initScreenCommandControls();
}());
+6 -1
View File
@@ -37,7 +37,12 @@
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
var clientName = String(client && client.client_name ? client.client_name : '').trim();
if (clientName) {
return clientName;
}
return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
+182 -139
View File
@@ -12,40 +12,122 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission;
const ALL_SCREENS_SLUG = '__all__';
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) {
if (typeof getScreenConnections !== 'function' || !screenSlug) {
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeConnectionBaseUrl(connection) {
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
async function fetchPlayerRegistrations() {
if (typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const response = await getScreenConnections(screenSlug);
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
if (!liveConnections.length) {
return [];
}
const normalizedConnectionId = String(connectionId || '').trim();
const liveConnection = normalizedConnectionId
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId;
})
: null;
const targetConnections = liveConnection ? [liveConnection] : liveConnections;
return Array.from(new Set(targetConnections.map(function (connection) {
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
const registrations = await common.fetchPlayerRegistrations(pool);
return Array.isArray(registrations) ? registrations : [];
} catch (_error) {
return [];
}
}
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
async function fetchScreenConnections(screenSlug) {
if (typeof getScreenConnections !== 'function') {
return [];
}
try {
const response = await getScreenConnections(screenSlug);
return Array.isArray(response && response.connections) ? response.connections : [];
} catch (_error) {
return [];
}
}
async function fetchAllScreenTargets() {
const [rows] = await pool.query(
`SELECT slug
FROM d_screens
WHERE slug IS NOT NULL
ORDER BY slug ASC`
);
return (rows || [])
.map(function (row) {
return {
slug: String(row && row.slug ? row.slug : '').trim()
};
})
.filter(function (row) {
return Boolean(row.slug);
});
}
async function resolvePlayerBaseUrlsForConnections(connections) {
const connectionList = Array.isArray(connections) ? connections : [];
const seen = new Set();
const registrations = await fetchPlayerRegistrations();
return connectionList.map(function (connection) {
const selectedPublicBaseUrl = normalizeConnectionBaseUrl(connection);
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
return '';
}
seen.add(selectedPublicBaseUrl);
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
}).filter(Boolean);
}
async function resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl) {
const normalizedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (!normalizedPublicBaseUrl) {
return '';
}
const registrations = await fetchPlayerRegistrations();
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
}
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
}));
return {
ok: true,
sent: results.filter(function (result) {
return result.status === 'fulfilled';
}).length,
results: results
};
}
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
}
async function forwardPlayerCommandForPlayerBaseUrl(screenSlug, playerBaseUrl, commandPayload, connectionId, deviceId) {
const targetBaseUrl = await resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl);
if (targetBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
}
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
}
function isRecentPlayerRegistration(player, staleSeconds) {
@@ -56,32 +138,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
async function resolveAllPlayerBaseUrls() {
if (!common || typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await common.fetchPlayerRegistrations(pool);
return Array.from(new Set((Array.isArray(players) ? players : [])
.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
.map(function (player) {
return String(player && player.public_base_url || '').trim().replace(/\/$/, '');
})
.filter(Boolean)));
} catch (_error) {
return [];
}
}
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || '');
const playerBaseUrl = String((req.body && req.body.playerBaseUrl) || req.query.playerBaseUrl || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
@@ -93,14 +156,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Command is required' });
}
if (slug === ALL_SCREENS_SLUG) {
if (command === 'setclientname' || command === 'moveclient') {
if (slug === '__all__') {
if (command !== 'reload' && command !== 'pause' && command !== 'blackout') {
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 targets = await fetchAllScreenTargets();
if (!targets.length) {
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
ok: true,
allScreens: true,
command: command,
targetScreenCount: 0,
targetPlayerCount: 0,
sent: 0
});
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
@@ -109,36 +183,41 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
if (command === 'pause' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') && commandPayload && typeof commandPayload === 'object') {
commandPayload.paused = req.body.paused;
}
await Promise.all(screenRows.map(function (screenRow) {
const screenSlug = String(screenRow && screenRow.slug || '').trim();
return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) {
if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
return Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined);
}));
}
const results = await Promise.allSettled(targets.map(async function (target) {
const liveConnections = await fetchScreenConnections(target.slug);
if (liveConnections.length) {
return forwardPlayerCommandForConnections(target.slug, liveConnections, commandPayload);
}
return forwardPlayerCommand(screenSlug, commandPayload);
});
return forwardPlayerCommand(target.slug, commandPayload);
}));
const sentCount = results.reduce(function (total, result) {
if (result.status !== 'fulfilled') {
return total;
}
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
}, 0);
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
allScreens: true,
command: command,
targetScreenCount: targets.length,
targetPlayerCount: sentCount,
sent: sentCount,
blackout: command === 'blackout' ? Boolean(commandPayload.blackout) : undefined,
paused: command === 'pause' ? Boolean(commandPayload.paused) : undefined
});
}
@@ -147,28 +226,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(404).json({ error: 'Screen not found' });
}
if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') {
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;
}
const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json(Object.assign({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null
}, result && typeof result === 'object' ? result : {}));
}
if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
@@ -231,12 +288,21 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
if (!onboardingRow) {
await forwardPlayerCommand(slug, {
command: command,
clientName: clientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined);
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: command,
clientName: clientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
} else {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: command,
clientName: clientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
}
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
@@ -323,52 +389,35 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
let liveConnections = [];
try {
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
const liveConnections = await fetchScreenConnections(slug);
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
let targetPlayerUrl = '';
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;
})
}) || liveConnections[0] || null
: null;
const sourcePlayerBaseUrl = String(
explicitPlayerBaseUrl ||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
if (sourcePlayerBaseUrl) {
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
}
if (!targetPlayerUrl) {
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
}
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
}, connectionId || deviceId || undefined, deviceId || null);
} else {
await forwardPlayerCommand(slug, {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
}, connectionId || deviceId || undefined, deviceId || null);
}
if (typeof broadcastDashboardState === 'function') {
@@ -395,16 +444,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
commandPayload.blackout = blackoutValue;
}
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId);
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId);
})).then(function (results) {
return Array.isArray(results) && results.length ? results[0] : { ok: true };
})
: (connectionId
? await forwardPlayerCommand(slug, commandPayload, connectionId)
: await forwardPlayerCommand(slug, commandPayload));
const liveConnections = await fetchScreenConnections(slug);
const result = playerBaseUrl
? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
+138 -14
View File
@@ -11,6 +11,7 @@ module.exports = function registerManageRoutes(app, deps) {
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const requirePermission = deps.requirePermission;
const SCREEN_NAME_MAX_LENGTH = 255;
@@ -25,13 +26,106 @@ module.exports = function registerManageRoutes(app, deps) {
return text.slice(0, limit);
}
async function fetchAllScreenSlugs() {
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
async function fetchPlayerRegistrations() {
if (typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const registrations = await common.fetchPlayerRegistrations(pool);
return Array.isArray(registrations) ? registrations : [];
} catch (_error) {
return [];
}
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
async function fetchLiveConnectionsForScreen(slug) {
if (typeof getScreenConnections !== 'function') {
return [];
}
try {
const response = await getScreenConnections(slug);
return Array.isArray(response && response.connections) ? response.connections : [];
} catch (_error) {
return [];
}
}
async function resolvePlayerBaseUrlsForConnections(connections) {
const connectionList = Array.isArray(connections) ? connections : [];
const seen = new Set();
const registrations = await fetchPlayerRegistrations();
return connectionList.map(function (connection) {
const selectedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
return '';
}
seen.add(selectedPublicBaseUrl);
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
}).filter(Boolean);
}
async function forwardPlayerCommandForConnections(slug, connections, commandPayload) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload);
}));
return {
ok: true,
sent: results.filter(function (result) {
return result.status === 'fulfilled';
}).length,
results: results
};
}
return forwardPlayerCommand(slug, commandPayload);
}
async function fetchAllScreenTargets() {
const [rows] = await pool.query(
`SELECT s.slug
FROM d_screens s
WHERE s.slug IS NOT NULL
ORDER BY s.slug ASC`
);
return (rows || [])
.map(function (row) {
return String(row && row.slug ? row.slug : '').trim();
return {
slug: String(row && row.slug ? row.slug : '').trim(),
playerId: '',
publicBaseUrl: '',
internalBaseUrl: ''
};
})
.filter(Boolean);
.filter(function (row) {
return Boolean(row.slug);
});
}
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
@@ -52,8 +146,8 @@ module.exports = function registerManageRoutes(app, deps) {
return res.status(400).json({ error: 'Unsupported command' });
}
const slugs = await fetchAllScreenSlugs();
if (!slugs.length) {
const targets = await fetchAllScreenTargets();
if (!targets.length) {
await broadcastDashboardState();
return res.json({ ok: true, command: command, sent: 0 });
}
@@ -70,7 +164,22 @@ module.exports = function registerManageRoutes(app, deps) {
}
: 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload);
const results = await Promise.allSettled(targets.map(async function (target) {
const liveConnections = await fetchLiveConnectionsForScreen(target.slug);
if (liveConnections.length) {
return forwardPlayerCommandForConnections(target.slug, liveConnections, payload);
}
return forwardPlayerCommand(target.slug, payload);
}));
const sentCount = results.reduce(function (total, result) {
if (result.status !== 'fulfilled') {
return total;
}
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
}, 0);
await broadcastDashboardState();
return res.json({
@@ -149,13 +258,28 @@ module.exports = function registerManageRoutes(app, deps) {
await notifyPlayerScreens([previousSlug], 'refresh');
}
if (previousSlug && previousSlug !== slug) {
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchPlayerPublicBaseUrl(pool)
: '';
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
const previousScreenTargets = await pool.query(
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
FROM d_screens s
LEFT JOIN d_players p ON p.device_id = s.player_id
WHERE s.slug = ?
LIMIT 1`,
[previousSlug]
);
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
command: 'redirect',
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
} else {
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
}
}
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens',
+2
View File
@@ -125,6 +125,8 @@ function registerSignageRoutes(app, deps) {
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
requirePermission: deps.requirePermission
});
+4 -4
View File
@@ -40,16 +40,16 @@
</div>
</div>
<div class="screen-command-actions">
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-async-command data-screen-command-form data-screen-command-action="reload">
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-screen-command-form data-screen-command-action="reload" data-async-command>
<input type="hidden" name="command" value="reload" />
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-async-command data-screen-command-form data-screen-command-action="pause">
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-screen-command-form data-screen-command-action="pause" data-async-command>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="paused" value="true" />
<button type="submit" class="btn btn-sm btn-info"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-async-command data-screen-command-form data-screen-command-action="blackout">
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-screen-command-form data-screen-command-action="blackout" data-async-command>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="true" />
<button type="submit" class="btn btn-sm btn-secondary"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
@@ -138,7 +138,7 @@
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
<button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button>
</form>
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="previous" />
<input type="hidden" name="connectionId" value="{{id}}" />
+150 -30
View File
@@ -44,7 +44,18 @@ test('move client rebinding redirects the live player to the target screen', asy
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
@@ -67,6 +78,19 @@ test('move client rebinding redirects the live player to the target screen', asy
};
}
if (slug === 'target-screen') {
return {
connections: [
{
id: 'target-conn-1',
clientId: 'target-conn-1',
deviceId: 'target-device-1',
playerPublicBaseUrl: 'https://remote-target.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
@@ -115,34 +139,78 @@ test('move client rebinding redirects the live player to the target screen', asy
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 === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
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')) {
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
{ slug: 'alpha' },
{ slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
getScreenConnections: async () => ({ connections: [] }),
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player-a.example'
}
]
};
}
if (slug === 'beta') {
return {
connections: [
{
id: 'beta-1',
clientId: 'beta-1',
deviceId: 'beta-device',
playerPublicBaseUrl: 'http://player-b.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
@@ -184,11 +252,13 @@ test('screen control commands can target all screens', async () => {
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(response.body.targetPlayerCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
test('screen control commands use the live connection player url when available', async () => {
test('screen control commands use the bridge for live connections', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -197,6 +267,17 @@ test('screen control commands use the live connection player url when available'
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[
{
slug: 'source-screen',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}
]];
}
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' }]];
}
@@ -204,15 +285,22 @@ test('screen control commands use the live connection player url when available'
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
@@ -268,11 +356,11 @@ test('screen control commands use the live connection player url when available'
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), false);
});
test('screen control commands fan out to every live player for the selected screen', async () => {
test('screen control commands fan out through the bridge for the selected screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -281,6 +369,15 @@ test('screen control commands fan out to every live player for the selected scre
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[{
slug: 'source-screen',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}]];
}
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' }]];
}
@@ -288,7 +385,18 @@ test('screen control commands fan out to every live player for the selected scre
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
@@ -305,7 +413,7 @@ test('screen control commands fan out to every live player for the selected scre
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player-a.example'
playerPublicBaseUrl: 'http://local-player.example'
},
{
id: 'conn-2',
@@ -357,14 +465,14 @@ test('screen control commands fan out to every live player for the selected scre
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), false);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://local-player-a.example', 'http://local-player-b.example']
['http://local-player.example', 'http://local-player-b.example']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
test('all screens commands fan out across the live player for each screen', async () => {
test('all screens commands fan out through the bridge for each screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -373,23 +481,34 @@ test('all screens commands fan out across the live player for each screen', asyn
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')) {
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
{ slug: 'alpha' },
{ slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
@@ -461,10 +580,11 @@ test('all screens commands fan out across the live player for each screen', asyn
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.equal(response.body.targetPlayerCount, 2);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.example', 'http://player-b.example']
['http://player-a.internal', 'http://player-b.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
+40
View File
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { registerFontSweepTask } = require('../src/web/lib/background-tasks/tasks-scheduled/font-sweep');
test('font sweep registers a generic cleanup recurring task without player metadata', async () => {
let registeredTask = null;
const calls = [];
registerFontSweepTask({
backgroundTaskQueue: {
registerRecurringTask(task) {
registeredTask = task;
}
},
mediaDir: '/tmp/media',
uploadSyncService: {
async pushUploadFileToPlayer(uploadPath, mediaDir) {
calls.push(['put', uploadPath, mediaDir]);
},
async removeUploadFileFromPlayer(uploadPath, mediaDir) {
calls.push(['delete', uploadPath, mediaDir]);
}
}
});
assert.ok(registeredTask);
assert.equal(registeredTask.key, 'font-sweep');
assert.equal(registeredTask.title, 'Font sweep');
assert.equal(registeredTask.category, 'cleanup');
assert.equal(registeredTask.intervalMs, 24 * 60 * 60 * 1000);
assert.deepEqual(registeredTask.metadata, { mediaDir: '/tmp/media' });
await registeredTask.run();
assert.deepEqual(calls, [
['put', '/media/fonts/fonts.json', '/tmp/media'],
['put', '/media/fonts/fonts.css', '/tmp/media']
]);
});
@@ -0,0 +1,33 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { registerOnboardingDevicePruneTask } = require('../src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune');
test('onboarding device prune registers an hourly recurring cleanup job', async () => {
let registeredTask = null;
const calls = [];
registerOnboardingDevicePruneTask({
backgroundTaskQueue: {
registerRecurringTask(task) {
registeredTask = task;
}
},
pool: {},
common: {
async pruneStaleOnboardingDevices(pool) {
calls.push(pool);
}
}
});
assert.ok(registeredTask);
assert.equal(registeredTask.key, 'onboarding-device-prune');
assert.equal(registeredTask.title, 'Onboarding device prune');
assert.equal(registeredTask.category, 'cleanup');
assert.equal(registeredTask.intervalMs, 60 * 60 * 1000);
await registeredTask.run();
assert.deepEqual(calls, [{}]);
});
+197
View File
@@ -0,0 +1,197 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
function createAppHarness() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('client command route forwards all screens commands', async () => {
const { app, handlers } = createAppHarness();
const calls = [];
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql).includes('FROM d_screens') && String(sql).includes('ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
}
return [[[]]];
}
},
common: {
async fetchPlayerRegistrations() {
return [
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
];
}
},
forwardPlayerCommand(screenSlug, commandPayload) {
calls.push({ kind: 'forwardPlayerCommand', screenSlug, commandPayload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, screenSlug, commandPayload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, screenSlug, commandPayload });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return { connections: [{ playerPublicBaseUrl: 'http://player-a.example' }] };
}
if (slug === 'beta') {
return { connections: [{ playerPublicBaseUrl: 'http://player-b.example' }] };
}
return { connections: [] };
},
isClientNameAvailable() {
return true;
},
withClientNameReservation() {
throw new Error('withClientNameReservation should not be called for __all__');
},
broadcastDashboardState() {
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(payload) {
this.body = payload;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: { command: 'reload' },
query: {},
currentUser: { id: 1 }
}, 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.equal(response.body.sent, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('client command route forwards a single screen command', async () => {
const { app, handlers } = createAppHarness();
const calls = [];
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
return [[{ id: 7, name: 'Demo Lobby', slug: 'demo-lobby' }]];
}
if (String(sql).includes('SELECT client_name')) {
return [[null]];
}
if (String(sql).includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[[]]];
}
if (String(sql).includes('UPDATE d_onboarding_devices')) {
return [{ affectedRows: 0 }];
}
if (String(sql).includes('FROM d_onboarding_devices d')) {
return [[[]]];
}
return [[[]]];
}
},
common: {
async fetchPlayerRegistrations() {
return [];
}
},
forwardPlayerCommand(screenSlug, commandPayload, connectionId) {
calls.push({ kind: 'forward', screenSlug, commandPayload, connectionId });
return Promise.resolve({ ok: true, sent: 1 });
},
forwardPlayerCommandToBaseUrl() {
throw new Error('forwardPlayerCommandToBaseUrl should not be called for this path');
},
getScreenConnections() {
return Promise.resolve({ connections: [] });
},
isClientNameAvailable() {
return true;
},
withClientNameReservation() {
throw new Error('withClientNameReservation should not be called for reload');
},
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(payload) {
this.body = payload;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'demo-lobby' },
body: { command: 'reload' },
query: {},
currentUser: { id: 1 }
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(calls.length, 1);
assert.equal(calls[0].kind, 'forward');
assert.equal(calls[0].screenSlug, 'demo-lobby');
assert.deepEqual(calls[0].commandPayload, { command: 'reload' });
assert.equal(calls[0].connectionId, undefined);
assert.equal(response.body.ok, true);
});
+93 -37
View File
@@ -5,7 +5,7 @@ require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
test('player actions use the configured bridge url for commands', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
@@ -29,20 +29,7 @@ test('player actions prefer the exact configured player registration over a remo
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'
}
]];
}
}
playerInternalBaseUrl: 'http://player:8081'
});
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
@@ -60,12 +47,57 @@ test('player actions prefer the exact configured player registration over a remo
}
});
test('player actions merge screen connections from every recent player registration', async () => {
test('player actions read screen connections from the bridge base url', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
if (String(url).includes('player-a.example')) {
return {
ok: true,
status: 200,
headers: { get() { return null; } },
async json() {
return {
screenSlug: 'demo',
connections: [
{ id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[[]]];
}
},
playerInternalBaseUrl: 'http://bridge.example'
});
try {
const response = await playerActionService.getScreenConnections('demo');
assert.equal(response.count, 1);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['bridge-1']);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.example/api/screens/demo/connections');
} finally {
global.fetch = originalFetch;
}
});
test('player actions merge bridge and player screen connections', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
if (String(url).indexOf('player-bridge:8090') !== -1) {
return {
ok: true,
status: 200,
@@ -74,7 +106,7 @@ test('player actions merge screen connections from every recent player registrat
return {
screenSlug: 'demo',
connections: [
{ id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
{ id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' }
]
};
},
@@ -92,7 +124,7 @@ test('player actions merge screen connections from every recent player registrat
return {
screenSlug: 'demo',
connections: [
{ id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' }
{ id: 'player-1', playerPublicBaseUrl: 'http://player.example' }
]
};
},
@@ -104,30 +136,54 @@ test('player actions merge screen connections from every recent player registrat
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-a',
public_base_url: 'http://player-a.example',
last_seen_at: new Date().toISOString()
},
{
identifier: 'player-b',
public_base_url: 'http://player-b.example',
last_seen_at: new Date().toISOString()
}
]];
}
}
playerInternalBaseUrl: 'http://player:8081',
bridgeInternalBaseUrl: 'http://player-bridge:8090'
});
try {
const response = await playerActionService.getScreenConnections('demo');
assert.equal(response.count, 2);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['a-1', 'b-1']);
assert.equal(fetchCalls.length, 2);
assert.deepEqual(fetchCalls.map(function (call) { return call.url; }).sort(), [
'http://player-bridge:8090/api/screens/demo/connections',
'http://player:8081/api/screens/demo/connections'
]);
assert.equal(response.count, 2);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }).sort(), ['bridge-1', 'player-1']);
} finally {
global.fetch = originalFetch;
}
});
test('player actions send device commands through the bridge base url', 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, sent: true };
},
async text() {
return JSON.stringify({ ok: true, sent: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
bridgeInternalBaseUrl: 'http://player-bridge:8090'
});
try {
const response = await playerActionService.forwardPlayerCommandToDevice('device-123', { command: 'pause' });
assert.deepEqual(response, { ok: true, sent: true });
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/players/device-123/commands');
} finally {
global.fetch = originalFetch;
}
+34 -7
View File
@@ -3,19 +3,19 @@ const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
delete process.env.WEB_BASE_URL;
delete process.env.WEB_INTERNAL_URL;
} else {
process.env.WEB_BASE_URL = originalWebBaseUrl;
process.env.WEB_INTERNAL_URL = originalWebBaseUrl;
}
});
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
test('resolveWebBaseUrl prefers WEB_INTERNAL_URL', () => {
process.env.WEB_INTERNAL_URL = 'https://web.example.test/app/';
const resolved = resolveWebBaseUrl({
headers: {
@@ -30,7 +30,7 @@ test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
});
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
delete process.env.WEB_BASE_URL;
delete process.env.WEB_INTERNAL_URL;
const resolved = resolveWebBaseUrl({
headers: {
@@ -60,4 +60,31 @@ test('resolveScreenCommandTargets only returns open sockets for the requested sc
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
test('resolveScreenCommandTargets falls back to a single open socket when the screen map is empty', () => {
const playerSockets = new Map([
['player-a', { readyState: 1, playerDeviceId: 'player-a' }]
]);
const screenPlayerDeviceIds = new Map();
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
test('resolveSnapshotUpstreamBaseUrl prefers a local internal player url', () => {
assert.equal(resolveSnapshotUpstreamBaseUrl({
public_base_url: 'https://pulse-dev-player.lzstealth.com',
internal_base_url: 'http://player-dev:8081'
}), 'http://player-dev:8081');
});
test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for remote players', () => {
assert.equal(resolveSnapshotUpstreamBaseUrl({
public_base_url: 'https://remote-player.example',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}), 'https://remote-player.example');
});
+1 -1
View File
@@ -108,7 +108,7 @@ function registerThinClientRoutes(fetchImpl) {
return 'form';
}
},
thinClientBaseUrl: 'http://bridge.test',
bridgeBaseUrl: 'http://bridge.test',
playerPublicBaseUrl: 'http://public.test'
});
+200
View File
@@ -9,6 +9,15 @@ function loadScript(scriptPath, sandbox) {
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
function loadHtmlScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8').match(/<script>([\s\S]*)<\/script>/);
if (!source) {
throw new Error(`Unable to extract script body from ${scriptPath}`);
}
const script = source[1].trim();
vm.runInNewContext(script, sandbox, { filename: scriptPath });
}
test('webpage preloading only targets the next slide', () => {
const sandbox = {
window: null,
@@ -95,4 +104,195 @@ test('rtmp warmups only target the next slide', () => {
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(','), '');
});
test('command client ids stay scoped to the screen session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
crypto: {
randomUUID() {
return 'tab-session-client-id';
}
},
localStorage,
sessionStorage,
commandClientId: null,
commandClientStorageKey: 'pulse-command-client-id',
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);
assert.equal(sandbox.getCommandClientId(), 'tab-session-client-id');
assert.equal(sessionStorage.getItem('pulse-command-client-id'), 'tab-session-client-id');
assert.equal(localStorage.getItem('pulse-command-client-id'), null);
});
test('player client names stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'tab-only-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'shared-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
WebSocket: { OPEN: 1 },
sendCommandState() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
assert.equal(sandbox.getOnboardingClientName(), 'tab-only-name');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
sandbox.applyOnboardingClientName('Renamed Tab Client', null);
assert.equal(sessionStorage.getItem('pulse-signage-player-client-name'), 'Renamed Tab Client');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
});
test('onboarding device ids stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-device-id', 'shared-device-id']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
crypto: {
randomUUID() {
return 'tab-device-id';
}
},
fetch() {
return Promise.resolve({
ok: true,
json() {
return Promise.resolve({ onboarded: false, screens: [] });
},
text() {
return Promise.resolve('');
}
});
},
document: {
getElementById() {
return null;
},
createElement() {
return { appendChild() {}, removeChild() {} };
}
},
location: {
replace() {}
},
setInterval() {
return 1;
},
clearInterval() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'onboarding', 'player-onboarding-landing.script.html'), sandbox);
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
});
+10 -1
View File
@@ -38,7 +38,13 @@ function waitFor(predicate, timeoutMs = 1000) {
test('player runtime snapshots websocket state and checks live names', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const snapshotNotifications = [];
const runtime = createPlayerRuntime({
pool: null,
notifySnapshot(snapshot) {
snapshotNotifications.push(snapshot);
}
});
const server = http.createServer();
runtime.installWebsocket(server);
@@ -70,6 +76,7 @@ test('player runtime snapshots websocket state and checks live names', async ()
const snapshot = runtime.snapshotConnections('test2')[0];
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
});
await waitFor(() => snapshotNotifications.length > 0);
const snapshot = runtime.snapshotConnections('test2')[0];
assert.equal(snapshot.clientName, 'Lobby Player');
@@ -78,6 +85,8 @@ test('player runtime snapshots websocket state and checks live names', async ()
assert.equal(snapshot.paused, true);
assert.equal(snapshot.currentSlideId, 9);
assert.equal(snapshot.currentSlideTitle, 'Intro');
assert.equal(snapshotNotifications[0].slug, 'test2');
assert.equal(snapshotNotifications[0].connections.length, 1);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
+97 -504
View File
@@ -89,273 +89,6 @@ test('dashboard kiosk launcher includes connected player choices', () => {
assert.match(html, /data-kiosk-launcher-download-base="\/downloads\/kiosk\/pulse-signage-kiosk\.bat"/);
});
test('dashboard kiosk launcher requires both confirmation and a player selection', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const downloadLink = {
classList: {
classes: new Set(['disabled']),
add(name) { this.classes.add(name); },
remove(name) { this.classes.delete(name); },
contains(name) { return this.classes.has(name); }
},
attributes: {
href: '',
'data-kiosk-launcher-download-base': '/downloads/kiosk/pulse-signage-kiosk.bat',
'aria-disabled': 'true',
tabindex: '-1'
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : '';
},
removeAttribute(name) {
delete this.attributes[name];
}
};
const checkbox = {
checked: false,
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const select = {
value: '',
options: [
{ value: '', textContent: 'Select a player' },
{ value: 'http://player-a.example', textContent: 'player-alpha' }
],
listeners: {},
innerHTML: '',
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const modal = {
querySelectorAll(selector) {
return selector === '[data-kiosk-launcher-download]' ? [downloadLink] : [];
},
querySelector(selector) {
if (selector === '[data-kiosk-launcher-confirm]') {
return checkbox;
}
if (selector === '[data-kiosk-launcher-player-select]') {
return select;
}
return null;
},
addEventListener(type, handler) {
this.listeners = this.listeners || {};
this.listeners[type] = handler;
}
};
const context = {
document: {
getElementById(id) {
if (id === 'dashboard-kiosk-launcher-modal') {
return modal;
}
return null;
},
querySelector() {
return null;
}
},
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);
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
checkbox.checked = true;
checkbox.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
select.value = 'http://player-a.example';
select.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), false);
assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example');
});
test('screen controls include an all screens option and update the target summary', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const select = {
value: '__all__',
selectedIndex: 2,
options: [
{ value: 'alpha', textContent: 'Alpha', getAttribute() { return null; } },
{ value: 'beta', textContent: 'Beta', getAttribute() { return null; } },
{ value: '__all__', textContent: 'All screens', getAttribute(name) { return name === 'data-screen-target-all' ? 'true' : null; } }
],
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const commandInput = { value: '' };
const pausedInput = { value: 'true' };
const button = {
innerHTML: '',
disabled: false
};
const form = {
dataset: {},
getAttribute(name) {
if (name === 'data-screen-command-action') {
return 'pause';
}
return this[name] || '';
},
querySelector(selector) {
if (selector === 'input[name="command"]') {
return commandInput;
}
if (selector === 'input[name="paused"]') {
return pausedInput;
}
if (selector === 'button[type="submit"]') {
return button;
}
return null;
},
querySelectorAll() {
return [button, commandInput, pausedInput];
},
setAttribute(name, value) {
this[name] = value;
},
action: ''
};
const pill = { classList: { toggle() {}, add() {}, remove() {} }, textContent: '' };
const nameNode = { textContent: '' };
const metaNode = { textContent: '' };
const context = {
document: {
getElementById(id) {
if (id === 'screen-command-select') {
return select;
}
return null;
},
querySelector(selector) {
if (selector === '[data-screen-command-pill]') {
return pill;
}
if (selector === '[data-screen-command-name]') {
return nameNode;
}
if (selector === '[data-screen-command-meta]') {
return metaNode;
}
return null;
},
querySelectorAll(selector) {
return selector === '[data-screen-command-form]' ? [form] : [];
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
clients: [
{ screen_slug: 'alpha', paused: true },
{ screen_slug: 'beta', paused: true }
]
});
assert.equal(form.action, '/clients/__all__/commands');
assert.equal(nameNode.textContent, 'All screens');
assert.equal(metaNode.textContent, 'Commands sent here target every client across every screen group.');
assert.match(button.innerHTML, /Resume all screens/);
assert.equal(commandInput.value, 'pause');
assert.equal(pausedInput.value, 'false');
});
test('dashboard client refresh respects the active search filter', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
@@ -428,10 +161,7 @@ test('dashboard client refresh respects the active search filter', () => {
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
FormData: function FormData() {}
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
@@ -449,54 +179,112 @@ test('dashboard client refresh respects the active search filter', () => {
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
});
test('dashboard client refresh respects the live search input before the URL updates', () => {
test('dashboard move client button opens the move modal for the selected row', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const searchInput = {
value: 'beta'
const modal = {
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
const form = {
dataset: {},
method: 'post',
action: '',
querySelector(selector) {
if (selector === 'button[type="submit"]') {
return { disabled: true };
}
return null;
},
addEventListener() {}
};
const table = {
const connectionInput = { value: '' };
const deviceInput = { value: '' };
const clientNameInput = { value: '' };
const playerBaseUrlInput = { value: '' };
const targetSelect = { value: 'alpha' };
targetSelect.options = [
{ value: 'alpha', disabled: false },
{ value: 'beta', disabled: false }
];
const row = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
if (name === 'data-client-screen-slug') {
return 'alpha';
}
if (name === 'data-client-id') {
return 'conn-123';
}
if (name === 'data-client-device-id') {
return 'device-123';
}
if (name === 'data-client-player-base-url') {
return 'http://player.local';
}
return '';
},
querySelector(selector) {
if (selector === 'td[data-label="Client"] > div') {
return { textContent: 'Lobby Client' };
}
return null;
}
};
const moveButton = {
closest(selector) {
if (selector === 'button[data-action="move-screen"]') {
return this;
}
if (selector === 'tr[data-client-key]') {
return row;
}
return null;
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
if (id === 'client-move-screen-modal') {
return modal;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
if (id === 'client-move-screen-form') {
return form;
}
if (id === 'client-move-screen-target') {
return targetSelect;
}
if (id === 'dashboard-clients-table') {
return { getAttribute() { return 'true'; } };
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search]') {
return searchInput;
if (selector === '[data-client-move-connection-id]') {
return connectionInput;
}
if (selector === '[data-client-move-device-id]') {
return deviceInput;
}
if (selector === '[data-client-move-client-name]') {
return clientNameInput;
}
if (selector === '[data-client-move-player-base-url]') {
return playerBaseUrlInput;
}
return null;
},
querySelectorAll() {
return [];
},
addEventListener(type, handler) {
if (type === 'click') {
this.clickHandler = handler;
}
}
},
window: {
location: {
search: '',
protocol: 'http:',
host: 'example.test'
},
@@ -515,106 +303,11 @@ test('dashboard client refresh respects the live search input before the URL upd
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({
clients: [
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
]
});
assert.match(tbody.innerHTML, /Beta/);
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
});
test('dashboard client refresh does not rerender while a table search is loading', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
return '';
}
};
const loadingMarker = {};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
pulseModal: {
show(element) {
element.__shown = true;
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search-loading="true"]') {
return loadingMarker;
}
if (selector === '[data-table-search]') {
return { value: 'beta' };
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '?search=beta',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
@@ -627,125 +320,25 @@ test('dashboard client refresh does not rerender while a table search is loading
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
FormData: function FormData() {}
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
clients: [
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
]
assert.equal(typeof context.document.clickHandler, 'function');
context.document.clickHandler({
target: moveButton,
preventDefault() {}
});
assert.equal(tbody.innerHTML, '<tr><td>stale</td></tr>');
});
test('dashboard client table can rehydrate actions after a search swap', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'true';
}
return '';
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search]') {
return { value: '48' };
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '?search=48',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
requestAnimationFrame(callback) {
callback();
},
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({
clients: [
{ id: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', client_name: 'Beta', clientId: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', screen_slug: 'beta', clientIp: '10.0.0.2' },
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' }
]
});
tbody.innerHTML = '<tr data-client-key="48f94e8d-a307-45f8-99a5-dafd3f0e2282"><td data-label="Client"><div>Beta</div></td></tr>';
context.window.webRefreshClientTableFromLatestState();
assert.match(tbody.innerHTML, /data-label="Actions"/);
assert.match(tbody.innerHTML, /Pause/);
assert.match(tbody.innerHTML, /Blackout/);
assert.equal(modal.__shown, true);
assert.equal(form.action, '/clients/alpha/commands');
assert.equal(targetSelect.options[0].disabled, true);
assert.equal(targetSelect.options[1].disabled, false);
assert.equal(connectionInput.value, 'conn-123');
assert.equal(deviceInput.value, 'device-123');
assert.equal(clientNameInput.value, 'Lobby Client');
assert.equal(playerBaseUrlInput.value, 'http://player.local');
});
+277 -5
View File
@@ -14,12 +14,33 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
const pool = {
async query(sql) {
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
return [[{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player.local',
internal_base_url: 'http://player.internal'
}]];
}
if (sql.includes('SELECT id, name, slug, playlist_id')) {
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
}
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[
{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
slug: 'beta',
player_id: 'player-b',
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
]];
}
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
@@ -37,7 +58,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
fetchPlayerPublicBaseUrl: async () => 'http://player.local',
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player.local',
internal_base_url: 'http://player.internal'
}
])
},
pages,
getAuditUserId() { return 7; },
@@ -53,11 +80,30 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player.local/'
}
]
};
}
return { connections: [] };
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
@@ -91,7 +137,233 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
assert.equal(res.redirectedTo, '/screens?edit=42');
assert.deepEqual(calls, [
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
]);
});
test('dashboard commands fan out to each connected bridge player', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const calls = [];
registerManageRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
return [[
{
slug: 'alpha',
},
{
slug: 'beta'
}
]];
}
return [[]];
}
},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
getAuditUserId() { return 7; },
redirectAfterSave() {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player-a.example/'
}
]
};
}
if (slug === 'beta') {
return {
connections: [
{
id: 'beta-1',
clientId: 'beta-1',
deviceId: 'beta-device',
playerPublicBaseUrl: 'http://player-b.example/'
}
]
};
}
return { connections: [] };
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: { command: 'pause' },
query: {}
};
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.sent, 2);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.internal', 'http://player-b.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('dashboard commands use live bridge connections when available', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const calls = [];
registerManageRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
return [[
{
slug: 'alpha',
}
]];
}
return [[]];
}
},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}
])
},
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
getAuditUserId() { return 7; },
redirectAfterSave() {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async (slug) => {
if (slug !== 'alpha') {
return { connections: [] };
}
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player-a.example/'
}
]
};
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: { command: 'reload' },
query: {}
};
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.sent, 1);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
+33
View File
@@ -0,0 +1,33 @@
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('client row keys prefer the client name', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'web-ui-helpers.js'), sandbox);
const helpers = sandbox.window.webUiHelpers;
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'Conference Left');
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'Conference Right');
assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'alpha');
assert.equal(helpers.getClientRowKey({ clientId: 'client-1' }), 'client-1');
});