Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:35:27 +01:00
parent a4f8a807ff
commit 2bd47fb96a
36 changed files with 5081 additions and 263 deletions
+56 -3
View File
@@ -2,6 +2,62 @@
All notable changes to this project will be documented in this file.
## 2.6.5 - 2026-08-07
### Added
- Multiple players are now supported across the player registry, dashboard snapshots, and screen group management, so a deployment can track more than one connected player at a time.
- The player-agent can now run remotely, which lets a player connect through the bridge instead of requiring everything to stay on the same host.
- The Docker Compose setup now includes a public stack with a player bridge service and a separate remote player stack, so local and split-device deployments share the same documented layout.
- Dedicated local and remote compose manifests now live at `docker-compose/docker-compose.yml` and `docker-compose/docker-compose.remote.yml`, with matching example env files for the two deployment modes.
- Admin client commands now have a dedicated route and test coverage, which keeps dashboard actions aligned with the current player control-plane flow.
### Changed
- Screen group views now show each registered player's public base URL and computed player URL on the edit screen instead of assuming a single local player.
- The dashboard status indicator now reports the connected player count, which makes the live feed status more explicit when several players are online.
- Player command dispatch, playlist playback, and render preloading were tightened so the player keeps using the active web base URL and can warm assets before the slide is shown.
- Playlist video-duration selection now uses the longest matching video region when a slide contains more than one video region.
- Media sync, font sync, and upload sync now follow the newer player-agent workflow, keeping background tasks and dashboard refreshes consistent with split-device deployments.
- The onboarding landing page now shows a built-in QR placeholder and falls back to it when the QR request fails, so the screen never starts blank.
- Upload sync now resolves the player internal base URL once per batch and reuses it for queued upload and delete operations, keeping mirrored media writes pointed at the active player endpoint.
- The connected clients list now defaults to client name, then IP address, so rows stay in a stable order when connection details change.
### Fixed
- Connected clients search results now keep the row action column, because the row template resolves `currentUser` from the parent view context while rendering inside the `clients` loop.
- The move-client modal now carries the row's player base URL through to the redirect step, so clients move correctly even when local and bridge-backed players are mixed.
- Paginated table cards now only apply their minimum height when the table content would otherwise exceed that threshold, which keeps short result sets compact in smaller browser windows.
## 2.6.4 - 2026-08-07
### Fixed
- The screen table no longer stores a player foreign key, and screen/player URLs now resolve from the current player registration instead of a screen assignment.
## 2.6.3 - 2026-08-07
### Fixed
- The screen table no longer enforces a foreign key to the player table, which keeps player assignments flexible while preserving the existing screen schema.
## 2.6.2 - 2026-08-07
### Fixed
- The player registry now upgrades to an integer player id with a separate player identifier so the schema migration can complete cleanly on existing databases.
- Dashboard screen lookups now tolerate the upgraded player table layout during the rollout.
## 2.6.1 - 2026-08-06
### Fixed
### Changed
- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback.
- Player registrations now store a separate friendly identifier, so a player can keep the same device key while showing a label like `Shop2`.
## 2.5.14 - 2026-08-06
### Added
@@ -9,9 +65,6 @@ All notable changes to this project will be documented in this file.
- Added branded Windows and Linux kiosk launcher downloads on the dashboard, with updated copy that explains the launcher behavior more clearly.
- Kiosk launchers now start the browser in kiosk mode, suppress notifications for Chromium-based browsers, and use the correct Firefox kiosk flag.
### Changed
- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback.
## 2.5.13 - 2026-08-05
+220
View File
@@ -0,0 +1,220 @@
# Docker Compose Setup
This folder contains the Docker Compose definitions for Pulse Signage, including the public stack and the remote player stack.
## Files
- [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL.
- [.env.example](.env.example) - sample environment values for the public stack.
- [docker-compose.remote.yml](docker-compose.remote.yml) - remote player-only stack for machines that sit behind the player bridge.
- [.env.remote.example](.env.remote.example) - sample environment values for the remote stack.
## Stack Overview
### Public stack
The main compose stack is the most complete setup. It runs:
- `web` - the dashboard and admin app.
- `player` - the local screen player.
- `player-bridge` - the bridge service that proxies dashboard commands and player communication.
- `mysql` - the database used by the web, player, and bridge services.
This is the stack to use when you want the full app running on one machine.
### Remote player
The remote stack runs only the `player` service.
Use it when the player is installed on a remote device and connects back through the player bridge instead of running the full app separately.
## Services
### `web`
The dashboard and admin application.
Responsibilities:
- serves the web UI on port `8080`
- reads and writes application data from MySQL
- forwards player actions through the configured bridge base URL
- renders connected clients, dashboard pages, and admin workflows
Key configuration:
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `PULSE_SIGNAGE_SHARED_SECRET`
- `SESSION_MAX_AGE_DAYS`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
- `PASSWORD_HASH_ITERATIONS`
### `player`
The screen runtime that renders playlists and receives commands.
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`
- registers live connections and accepts control commands
Key configuration:
- `PLAYER_PUBLIC_BASE_URL`
- `PLAYER_INTERNAL_BASE_URL`
- `PLAYER_IDENTIFIER`
- `THIN_CLIENT_BASE_URL` in remote mode
- `PULSE_SIGNAGE_SHARED_SECRET`
- database settings in local mode
### `player-bridge`
The bridge layer that connects the dashboard to the player network.
Responsibilities:
- serves the bridge API on port `8090`
- forwards authenticated dashboard commands to registered players
- exposes screen connection snapshots and player registration data
- proxies command traffic between the web app and remote players
Key configuration:
- `PULSE_SIGNAGE_SHARED_SECRET`
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
### `mysql`
The MySQL 8.4 database used by the public stack.
Responsibilities:
- stores application data, screen state, onboarding state, and registry records
- provides persistent storage through `mysql_data`
Key configuration:
- `MYSQL_DATABASE`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
## Environment Files
### `.env.example`
Use this file as a starting point for the public compose stack.
Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services
- `PULSE_SIGNAGE_SHARED_SECRET` - 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
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
- `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
### `.env.remote.example`
Use this file on a remote player device.
Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
### `PULSE_SIGNAGE_SHARED_SECRET`
This secret is the shared signing key for requests between the services. Use a single value for every service that needs to talk to the same stack, including the web app, player, bridge, and any remote player that connects back to that bridge.
Recommended shape:
- at least 32 random bytes
- ideally 64 hex characters, or another equally long cryptographically random string
- not a password, phrase, or anything human-readable
If you want a quick local value, generate one with a password manager or a command such as `openssl rand -hex 32`.
Leave it blank only if you intentionally want to run without request signing in a throwaway local setup.
## Main Configuration Variables
| Variable | Used By | Purpose |
| --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
| `DB_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. |
| `DB_NAME` | web, player, bridge, mysql | Database name. |
| `DB_USER` | web, player, bridge, mysql | Database user. |
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
| `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. |
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. |
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
## Ports
Public stack ports:
- `8080` - web dashboard
- `8081` - player
- `8090` - player bridge
- `3306` - MySQL
Remote stack ports:
- `8081` - player only
## Volumes
### Public stack
- `mysql_data` - persistent MySQL data.
- `pulse-signage` - shared media and cache volume for the app services.
### Remote stack
- `pulse-signage` - shared media and cache volume for the remote player.
## Networks
Each compose file creates its own named network:
- `pulse-signage` for the public stack
- `pulse-signage-remote` for remote player deployment.
## Notes
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases.
## Recommended Setup
1. Copy `.env.example` to a local `.env` file for the public stack.
2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player.
3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere.
4. Start the public stack first, then start the remote player after the bridge is reachable.
5. Verify that the player appears in Connected clients before testing screen commands.
+17
View File
@@ -78,6 +78,11 @@ Response fields:
Returns the upload directory configured for the player service.
Access: internal-only.
Response fields:
- `mediaDir`
- `uploadDir`
### `PUT /api/media/{filename}`
Writes an uploaded file into the player upload directory.
Access: internal-only and write-protected behind your deployment boundary.
@@ -95,6 +100,14 @@ Query fields:
- `source` required
- `disableAudio` optional
Response fields:
- `key`
- `playlistUrl`
- `disableAudio`
- `ready`
- `live`
### `GET /api/rtmp/streams/{key}/index.m3u8`
Returns the RTMP session HLS manifest.
Access: internal-only.
@@ -114,6 +127,7 @@ Response fields:
- `slides`
- `rssFeeds`
- `apiSources`
- `timetableGroups`
- `revision`
### `GET /api/screens/{slug}/connections`
@@ -198,6 +212,7 @@ Response fields:
`GET /api/media/config` returns an object with:
- `mediaDir`
- `uploadDir`
### RTMP Session Response
@@ -208,6 +223,7 @@ Response fields:
- `playlistUrl`
- `disableAudio`
- `ready`
- `live`
### Onboarding Write Response
@@ -230,6 +246,7 @@ Response fields:
- `slides`
- `rssFeeds`
- `apiSources`
- `timetableGroups`
- `revision`
### Connections Response
+3 -2
View File
@@ -21,7 +21,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `a_roles`
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `role_key` is unique.
- `name` is unique.
### `a_permissions`
@@ -93,7 +94,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_playlist_slides`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
+925
View File
@@ -0,0 +1,925 @@
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('../common');
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
const { createPlayerPlaylistService } = require('../player/playlist');
const { buildThumbnailPreviewData } = require('../player/thumbnail-preview');
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
const { createStyledQrCodeSvg } = require('../data/qr-code');
const { verifyPageAuthToken } = require('#src/request-auth');
function createThinClientConfig() {
return {
port: Number(process.env.THIN_CLIENT_PORT || 8090),
mediaDir: String(process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media')).trim()
};
}
function logBridge(message, details) {
if (details === undefined) {
console.info(`[player-bridge] ${message}`);
return;
}
console.info(`[player-bridge] ${message}`, details);
}
function normalizeRemoteAddress(value) {
const address = String(value || '').trim();
if (!address) {
return '';
}
return address.toLowerCase().startsWith('::ffff:') ? address.slice(7) : address;
}
function formatPlayerConnectionLabel(deviceId, remoteAddress) {
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress);
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
}
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
const key = String(slug || '').trim();
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
return [];
}
const deviceIds = screenPlayerDeviceIds.get(key);
if (!Array.isArray(deviceIds) || !deviceIds.length) {
return [];
}
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;
}
return { deviceId: deviceId, socket: socket };
}).filter(Boolean);
}
function resolveWebBaseUrl(req) {
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
if (configuredWebBaseUrl) {
return configuredWebBaseUrl;
}
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
if (!host) {
return null;
}
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
let url = null;
try {
url = new URL(`${protocol}://${host}`);
} catch (_error) {
return null;
}
if (url.port === '8090') {
url.port = '8080';
} else if (!url.port) {
const forwardedPort = String(req && req.headers && req.headers['x-forwarded-port'] || '').trim().split(',')[0];
if (forwardedPort) {
url.port = forwardedPort === '8090' ? '8080' : forwardedPort;
} else if (protocol === 'http') {
url.port = '8080';
}
}
return url.toString().replace(/\/$/, '');
}
async function start() {
const app = express();
const server = http.createServer(app);
const pool = common.createPool();
const config = createThinClientConfig();
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common,
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
});
app.use(express.json());
const playersWs = new WebSocketServer({ noServer: true });
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
const playerSockets = new Map();
const screenSnapshotCache = new Map();
const screenPlayerDeviceIds = new Map();
const pendingPlayerCommands = new Map();
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;
}
}
async function fetchPlayerSnapshotRegistrations() {
if (typeof common.fetchPlayerRegistrations === 'function') {
return common.fetchPlayerRegistrations(pool);
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at
FROM d_players
ORDER BY modified_at DESC, identifier ASC`
);
return rows;
}
function getConnectedPlayerSocket() {
for (const socket of playerSockets.values()) {
if (socket && socket.readyState === WebSocket.OPEN) {
return socket;
}
}
return null;
}
function getConnectedPlayerCount() {
return Array.from(playerSockets.values()).filter(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
}).length;
}
function getConnectedPlayerDeviceId() {
const socket = getConnectedPlayerSocket();
return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : '';
}
function removeConnectedPlayerSocket(socket) {
if (!socket || !socket.playerDeviceId) {
return false;
}
const current = playerSockets.get(socket.playerDeviceId);
if (current !== socket) {
return false;
}
playerSockets.delete(socket.playerDeviceId);
return true;
}
function logPlayerDisconnect(socket) {
if (!socket || !socket.playerDeviceId) {
return;
}
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`);
}
function resolveMediaPath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
return relativePath;
}
function sendPlayerCommand(commandPayload) {
const socket = getConnectedPlayerSocket();
if (!socket) {
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
}
return sendPlayerCommandToSocket(socket, commandPayload);
}
function sendPlayerCommandToSocket(socket, commandPayload) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
}
const requestId = crypto.randomUUID();
const payload = Object.assign({
type: 'command',
requestId: requestId
}, commandPayload || {});
return new Promise(function (resolve) {
const timeout = setTimeout(function () {
pendingPlayerCommands.delete(requestId);
resolve({ ok: false, status: 504, error: 'Player command timed out.' });
}, 10000);
pendingPlayerCommands.set(requestId, {
resolve: function (message) {
clearTimeout(timeout);
pendingPlayerCommands.delete(requestId);
resolve(message);
}
});
try {
socket.send(JSON.stringify(payload));
} catch (error) {
clearTimeout(timeout);
pendingPlayerCommands.delete(requestId);
resolve({ ok: false, status: 502, error: error && error.message ? error.message : 'Unable to send player command.' });
}
});
}
function getScreenPlayerDeviceIds(slug) {
const key = String(slug || '').trim();
const deviceIds = screenPlayerDeviceIds.get(key);
return Array.isArray(deviceIds) ? deviceIds.slice() : [];
}
function storeScreenSnapshot(slug, connections, deviceIds) {
const key = String(slug || '').trim();
const normalizedConnections = Array.isArray(connections) ? connections : [];
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean) : [];
screenSnapshotCache.set(key, {
slug: key,
count: normalizedConnections.length,
connections: normalizedConnections
});
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
}
app.get('/health', function (_req, res) {
res.json({ ok: true, service: 'player-bridge' });
});
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
app.get('/api/players', requireRequestAuth, async function (_req, res, next) {
try {
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at
FROM d_players
ORDER BY modified_at DESC, identifier ASC`
);
res.json({ players: rows });
} catch (error) {
next(error);
}
});
app.get('/api/players/connected-count', requireRequestAuth, function (_req, res) {
res.json({ connectedPlayersCount: getConnectedPlayerCount() });
});
app.get('/api/screens/:slug/connections', requireRequestAuth, async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
if (!slug) {
return res.status(400).json({ error: 'Screen slug is required.' });
}
const snapshot = screenSnapshotCache.get(slug) || { slug: slug, count: 0, connections: [] };
res.json({
screenSlug: slug,
count: Number(snapshot.count || 0),
connections: Array.isArray(snapshot.connections) ? snapshot.connections : []
});
} catch (error) {
next(error);
}
});
function requirePageAuth(allowedScopes) {
return function (req, res, next) {
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
}
req.playerPageAuth = payload;
next();
};
}
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
res.json({
mediaDir: config.mediaDir,
uploadDir: path.join(config.mediaDir, 'uploads')
});
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const relativePath = resolveMediaPath(req.params.filename);
if (!relativePath) {
return res.status(400).json({ error: 'Filename is required' });
}
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const response = await sendPlayerCommand({
command: 'media-put',
relativePath: relativePath,
bodyBase64: bodyBuffer.toString('base64')
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) {
next(error);
}
});
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try {
const relativePath = resolveMediaPath(req.params.filename);
if (!relativePath) {
return res.status(400).json({ error: 'Filename is required' });
}
const response = await sendPlayerCommand({
command: 'media-delete',
relativePath: relativePath
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', requireRequestAuth, 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 blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
if (!slug) {
return res.status(400).json({ error: 'Screen slug is required.' });
}
if (!command) {
return res.status(400).json({ error: 'Command is required.' });
}
const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds);
if (!targetPlayers.length) {
return res.status(404).json({ error: 'Player is not connected.' });
}
const payload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command, screenSlug: slug })
: { command: command, screenSlug: slug };
if (command === 'blackout' && blackoutValue !== undefined) {
payload.blackout = blackoutValue;
}
const results = await Promise.all(targetPlayers.map(async function (target) {
const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {});
const response = await sendPlayerCommandToSocket(target.socket, requestBody);
return Object.assign({
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : (response && response.ok ? 200 : 502),
playerIdentifier: String(target.deviceId || '').trim()
}, response && typeof response === 'object' ? response : {});
}));
res.json({
ok: true,
screenSlug: slug,
command: command,
connectionId: connectionId || null,
sent: results.filter(function (result) { return result && result.ok; }).length,
results: results
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/status', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
const status = await getOnboardingStatus(pool, deviceId);
res.json({
deviceId: normalizeDeviceId(deviceId),
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/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');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId();
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
if (!deviceId) {
return res.status(503).json({ error: 'Player is not connected.' });
}
if (!clientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (!screenSlug) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []);
await bindPlayerToScreen(pool, deviceId, screenSlug);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
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 ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
const statusCode = Number(error && error.statusCode || error && error.status || 500);
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
error: error && error.message ? error.message : 'Onboarding failed.'
});
}
});
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
if (!data.screen) {
return res.status(404).json({ error: 'Screen not found' });
}
const etag = '"' + String(data.revision || '') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json(data);
} catch (error) {
next(error);
}
});
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const announcement = typeof common.fetchActiveAnnouncement === 'function'
? await common.fetchActiveAnnouncement(pool, req.params.slug)
: null;
const revision = announcement
? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':')
: 'none';
const etag = '"' + String(revision || 'none') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json({
announcement: announcement,
revision: revision
});
} catch (error) {
next(error);
}
});
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const data = buildThumbnailPreviewData(slide);
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
const rssData = await common.fetchRssFeedsData(pool);
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
return Object.assign({}, feed, {
items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
})
});
}));
}
if (typeof common.fetchApiSourcesData === 'function') {
const apiData = await common.fetchApiSourcesData(pool);
data.apiSources = (apiData.apiSources || []).map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
});
}
if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) {
next(error);
}
});
app.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.params.deviceId);
const socket = playerSockets.get(deviceId);
const payload = req.body && typeof req.body === 'object' ? req.body : {};
if (!socket || socket.readyState !== WebSocket.OPEN) {
return res.status(404).json({ ok: false, error: 'Player is not connected.' });
}
socket.send(JSON.stringify(payload));
res.json({ ok: true, deviceId: deviceId, sent: true });
} catch (error) {
next(error);
}
});
async function handlePlayerMessage(socket, rawMessage) {
let payload = null;
try {
payload = JSON.parse(String(rawMessage || '{}'));
} catch (_error) {
socket.send(JSON.stringify({ type: 'error', error: 'Invalid JSON payload.' }));
return;
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
socket.send(JSON.stringify({ type: 'error', error: 'Invalid player payload.' }));
return;
}
if (String(payload.type || '').trim() === 'command-response') {
const requestId = String(payload.requestId || '').trim();
if (requestId && pendingPlayerCommands.has(requestId)) {
pendingPlayerCommands.get(requestId).resolve(payload);
}
return;
}
const messageType = String(payload.type || '').trim().toLowerCase();
const deviceId = normalizeDeviceId(payload.deviceId || payload.playerIdentifier || socket.playerDeviceId || '');
if (!deviceId) {
socket.send(JSON.stringify({ type: 'error', error: 'Device ID is required.' }));
return;
}
socket.playerDeviceId = deviceId;
if (messageType === 'register') {
const player = await upsertPlayerRegistration(pool, {
deviceId: deviceId,
publicBaseUrl: payload.publicBaseUrl,
internalBaseUrl: payload.internalBaseUrl
});
playerSockets.set(deviceId, socket);
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`);
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
return;
}
if (messageType === 'heartbeat') {
const player = await recordPlayerHeartbeat(pool, {
deviceId: deviceId,
publicBaseUrl: payload.publicBaseUrl,
internalBaseUrl: payload.internalBaseUrl
});
socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player }));
return;
}
socket.send(JSON.stringify({ type: 'error', error: 'Unsupported player message type.' }));
}
server.on('upgrade', function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
if (pathname !== '/ws/players') {
const screenMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
if (!screenMatch) {
socket.destroy();
return;
}
if (!verifyRequestAuth(request)) {
socket.destroy();
return;
}
screenSnapshotsWs.handleUpgrade(request, socket, head, function (ws) {
screenSnapshotsWs.emit('connection', ws, request, decodeURIComponent(screenMatch[1]), 'screen-snapshots');
});
return;
}
if (!verifyRequestAuth(request)) {
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
socket.destroy();
return;
}
playersWs.handleUpgrade(request, socket, head, function (ws) {
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
playersWs.emit('connection', ws, request);
});
});
playersWs.on('connection', function (socket) {
socket.send(JSON.stringify({ type: 'ready', channel: 'player-bridge' }));
socket.on('message', function (message) {
handlePlayerMessage(socket, message).catch(function (error) {
console.error(error);
socket.send(JSON.stringify({ type: 'error', error: 'Player bridge request failed.' }));
});
});
socket.on('close', function () {
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
});
socket.on('error', function () {
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
});
});
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()
}));
}
function closeUpstreamSockets() {
upstreamSockets.forEach(function (upstreamSocket) {
try {
upstreamSocket.close();
} catch (_error) {
}
});
upstreamSockets.clear();
upstreamSnapshots.clear();
}
async function refreshUpstreams() {
if (closed || socket.readyState !== WebSocket.OPEN || !normalizedSlug) {
return;
}
let players = [];
try {
players = await fetchPlayerSnapshotRegistrations();
} catch (_error) {
return;
}
const seenKeys = new Set();
players.forEach(function (player) {
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
if (!baseUrl) {
return;
}
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
seenKeys.add(sourceKey);
upstreamDeviceIds.add(sourceKey);
if (upstreamSockets.has(sourceKey)) {
return;
}
const upstreamUrl = new URL(baseUrl.replace(/^http/, 'ws'));
upstreamUrl.pathname = `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`;
upstreamUrl.search = '';
const upstreamHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`
});
const upstreamSocket = new WebSocket(upstreamUrl.toString(), { headers: upstreamHeaders });
upstreamSockets.set(sourceKey, upstreamSocket);
upstreamSocket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
return;
}
upstreamSnapshots.set(sourceKey, {
slug: normalizedSlug,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
sendMergedSnapshot();
} catch (_error) {
}
};
upstreamSocket.onclose = function () {
upstreamSockets.delete(sourceKey);
upstreamSnapshots.delete(sourceKey);
if (!closed) {
sendMergedSnapshot();
}
};
upstreamSocket.onerror = function () {
try {
upstreamSocket.close();
} catch (_error) {
}
};
});
Array.from(upstreamSockets.keys()).forEach(function (sourceKey) {
if (!seenKeys.has(sourceKey)) {
const upstreamSocket = upstreamSockets.get(sourceKey);
upstreamSockets.delete(sourceKey);
upstreamSnapshots.delete(sourceKey);
upstreamDeviceIds.delete(sourceKey);
try {
upstreamSocket.close();
} catch (_error) {
}
}
});
sendMergedSnapshot();
}
refreshUpstreams();
refreshTimer = setInterval(function () {
refreshUpstreams().catch(function (_error) {
});
}, 5000);
socket.on('close', function () {
closed = true;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
closeUpstreamSockets();
});
socket.on('error', function () {
closed = true;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
closeUpstreamSockets();
});
});
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try {
const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' });
}
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
}))
});
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
} catch (error) {
next(error);
}
});
fs.mkdirSync(config.mediaDir, { recursive: true });
server.listen(config.port, function () {
console.log(`Player bridge listening on port ${config.port}`);
});
}
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets };
if (require.main === module) {
start().catch(function (error) {
console.error(error);
process.exit(1);
});
}
+317 -14
View File
@@ -2,6 +2,7 @@ const express = require('express');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { WebSocket } = require('ws');
const common = require('./common');
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
@@ -10,16 +11,20 @@ const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding }
const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
// Player runtime, media API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const pool = String(process.env.THIN_CLIENT_BASE_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 PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const PLAYER_IDENTIFIER = '1';
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_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');
@@ -29,7 +34,9 @@ async function start() {
pool: pool,
normalizeDeviceId: normalizeDeviceId
});
const playerPlaylistService = createPlayerPlaylistService({
const playerPlaylistService = isRemotePlayer
? null
: createPlayerPlaylistService({
pool: pool,
common: common,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
@@ -40,13 +47,157 @@ async function start() {
const server = http.createServer(app);
playerRuntime.installWebsocket(server);
app.use(express.json());
let hasLoggedPlayerStartup = false;
function logPlayerStartup(connectionState) {
if (hasLoggedPlayerStartup) {
return;
}
hasLoggedPlayerStartup = true;
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
});
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
function resolveLocalMediaFilePath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const resolvedMediaDir = path.resolve(MEDIA_DIR);
const resolvedFilePath = path.resolve(MEDIA_DIR, relativePath);
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
async function triggerWebMediaSync() {
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) {
return false;
}
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
});
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
}, authHeaders)
});
return Boolean(response && response.ok);
} catch (_error) {
return false;
}
}
let webMediaSyncCompleted = false;
async function handleThinClientCommand(socket, rawMessage) {
let payload = null;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || String(payload.type || '').trim() !== 'command') {
return;
}
const requestId = String(payload.requestId || '').trim() || null;
const command = String(payload.command || '').trim().toLowerCase();
const response = {
type: 'command-response',
requestId: requestId,
ok: false
};
try {
if (command === 'media-put') {
const relativePath = String(payload.relativePath || payload.filename || '').trim();
const filePath = resolveLocalMediaFilePath(relativePath);
const bodyBase64 = String(payload.bodyBase64 || '').trim();
if (!filePath || !bodyBase64) {
response.error = 'Invalid media payload.';
} else {
const bodyBuffer = Buffer.from(bodyBase64, 'base64');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, bodyBuffer);
response.ok = true;
}
} else if (command === 'media-delete') {
const relativePath = String(payload.relativePath || payload.filename || '').trim();
const filePath = resolveLocalMediaFilePath(relativePath);
if (!filePath) {
response.error = 'Invalid media path.';
} else {
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
response.ok = true;
}
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
if (!screenSlug) {
response.error = 'Screen slug is required.';
} else if (payload.connectionId) {
const sent = await playerRuntime.sendCommandToConnection(screenSlug, String(payload.connectionId || '').trim(), payload);
response.ok = sent > 0;
if (!response.ok) {
response.error = 'Player is not connected.';
}
} else {
const sent = await playerRuntime.broadcastCommand(screenSlug, payload);
response.ok = sent > 0;
if (!response.ok) {
response.error = 'Player is not connected.';
}
}
} else {
response.error = 'Unsupported command.';
}
} catch (error) {
response.error = error && error.message ? error.message : 'Command failed.';
}
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(response));
}
}
app.use(function (error, _req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
await ensureFontLibrary(MEDIA_DIR);
registerPlayerOnboardingRoutes(app, {
pool: pool,
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
registerPlayerRoutes(app, {
pool: pool,
@@ -58,24 +209,165 @@ async function start() {
rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
playerIdentifier: PLAYER_IDENTIFIER
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
app.use(function (error, _req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
function createThinClientWebSocketUrl() {
if (!THIN_CLIENT_BASE_URL) {
return null;
}
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
}
function startThinClientRegistration() {
const thinClientUrl = createThinClientWebSocketUrl();
if (!thinClientUrl) {
return null;
}
let socket = null;
let reconnectTimer = null;
let heartbeatTimer = null;
function clearTimers() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function connect() {
clearTimers();
const timestamp = String(Date.now());
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: '/ws/players',
timestamp: timestamp
});
let webMediaSyncTriggered = false;
socket = new WebSocket(thinClientUrl, {
headers: Object.assign({
'x-pulse-request-timestamp': timestamp
}, authHeaders)
});
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await ensureFontLibrary(MEDIA_DIR);
socket.on('open', function () {
logPlayerStartup({
connected: true
});
socket.send(JSON.stringify({
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
}, DB_SYNC_INTERVAL_MS);
});
socket.on('message', function (rawMessage) {
handleThinClientCommand(socket, rawMessage).catch(function (error) {
try {
socket.send(JSON.stringify({
type: 'command-response',
requestId: null,
ok: false,
error: error && error.message ? error.message : 'Command failed.'
}));
} catch (_sendError) {
// ignore send errors
}
});
});
socket.on('close', function () {
clearTimers();
reconnectTimer = setTimeout(connect, 5000);
});
socket.on('error', function () {
try {
socket.close();
} catch (_error) {
// ignore reconnect noise
}
});
}
connect();
return function stop() {
clearTimers();
if (socket) {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
socket = null;
}
};
}
if (!isRemotePlayer) {
logPlayerStartup({
connected: false
});
}
const stopThinClientRegistration = startThinClientRegistration();
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
});
async function syncDatabaseState() {
if (isRemotePlayer) {
return;
}
try {
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}).catch(function (error) {
console.error(error);
});
if (playerRuntime.snapshotAllConnections().length > 0) {
await common.pruneStaleOnboardingDevices(pool);
@@ -98,16 +390,27 @@ async function start() {
await syncDatabaseState();
if (PLAYER_IDENTIFIER) {
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null);
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) {
console.error(error);
});
}
setInterval(function () {
if (isRemotePlayer) {
return;
}
syncDatabaseState().catch(function (error) {
console.error(error);
});
}, DB_SYNC_INTERVAL_MS);
process.on('exit', function () {
if (typeof stopThinClientRegistration === 'function') {
stopThinClientRegistration();
}
});
}
module.exports = { start };
@@ -11,6 +11,7 @@
var localForm = document.getElementById("onboarding-local-form");
var localMessage = document.getElementById("onboarding-message");
var localScreenSelect = document.getElementById("onboarding-screen-select");
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
@@ -60,7 +61,11 @@
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
}
function loadQr(deviceId) {
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
if (!qr) { return; }
qr.onerror = function () {
qr.src = qrPlaceholderSrc;
};
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
}
function submitOnboarding(deviceId, clientName, screenSlug) {
return fetch("/api/onboarding", {
@@ -133,6 +138,9 @@
});
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc;
}
loadQr(deviceId);
setStatus("Waiting for onboarding to finish.");
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
+5 -2
View File
@@ -144,11 +144,14 @@ function createPlayerPlaylistService(options) {
return null;
}
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
const duration = videoRegions.reduce(function (longest, region) {
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
return regionDuration > longest ? regionDuration : longest;
}, 0);
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
+1 -1
View File
@@ -46,7 +46,7 @@ function renderOnboardingLandingBody() {
' <div class="onboarding-layout">',
' <div class="onboarding-qr-pane">',
' <div class="onboarding-qr-frame">',
' <img id="onboarding-qr" alt="Onboarding QR code" />',
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
' </div>',
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
' </div>',
+43 -4
View File
@@ -21,8 +21,8 @@ function createWebBootstrap(options) {
const dashboardWs = new WebSocketServer({ noServer: true });
const dashboardClients = new Set();
const playerSnapshotCache = new Map();
const playerSnapshotSockets = new Map();
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
let dashboardRefreshInFlight = null;
let broadcastDashboardState = null;
let playerInternalBaseUrl = null;
@@ -94,6 +94,10 @@ function createWebBootstrap(options) {
}
function ensurePlayerSnapshotSubscription(slug) {
if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) {
return options.ensurePlayerSnapshotSubscription(slug);
}
const key = String(slug || '').trim();
if (!key || playerSnapshotSockets.has(key)) {
return;
@@ -175,12 +179,44 @@ function createWebBootstrap(options) {
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
let lastDashboardState = null;
function getFallbackDashboardState() {
return lastDashboardState || {
playlists: [],
screens: [],
clients: [],
kioskPlayers: [],
slides: [],
playerServiceConnected: false,
connectedPlayersCount: 0,
connectedClientsCount: 0
};
}
async function resolveDashboardState() {
try {
const state = await buildDashboardState();
lastDashboardState = state;
return state;
} catch (error) {
if (lastDashboardState) {
console.error(error);
return lastDashboardState;
}
throw error;
}
}
async function sendDashboardStateToSocket(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const state = await buildDashboardState();
const state = await resolveDashboardState().catch(function (error) {
console.error(error);
return getFallbackDashboardState();
});
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
}
@@ -190,7 +226,10 @@ function createWebBootstrap(options) {
}
dashboardRefreshInFlight = (async function () {
const state = await buildDashboardState();
const state = await resolveDashboardState().catch(function (error) {
console.error(error);
return getFallbackDashboardState();
});
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
for (const socket of dashboardClients) {
if (socket && socket.readyState === WebSocket.OPEN) {
+4
View File
@@ -67,7 +67,11 @@ function createDashboardStateService(options) {
? await common.fetchScreenPlayerUrls(pool)
: {};
screensData.forEach(function (screen) {
try {
ensurePlayerSnapshotSubscription(screen.slug);
} catch (_error) {
// Keep building dashboard state when one player snapshot subscription fails.
}
});
const [onboardingRows] = await pool.query(
+216 -36
View File
@@ -6,11 +6,48 @@ const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
function 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;
}
function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -20,55 +57,144 @@ function createUploadSyncService(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const MAX_FIELD_BYTES = 10 * 1024 * 1024;
const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000;
const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null;
let pendingPlayerUploadSyncRetryLogAt = 0;
const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null;
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
}
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
const metadata = await getPlayerTaskMetadata();
if (!metadata || metadata.playerActive === false) {
return null;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
async function getPlayerTaskMetadata() {
if (playerTaskMetadata) {
return playerTaskMetadata;
}
if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise;
}
playerTaskMetadataPromise = (async function () {
try {
const [rows] = await pool.query(
`SELECT internal_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1`
);
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
} catch (_error) {
return configuredPlayerInternalBaseUrl || null;
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const registeredPlayers = Array.isArray(players) ? players : [];
const recentPlayers = registeredPlayers.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
});
const preferredPlayer = recentPlayers.find(function (player) {
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || recentPlayers.find(function (player) {
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
}) || recentPlayers[0] || null;
if (preferredPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: resolvedIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
})().then(function (baseUrl) {
playerInternalBaseUrl = baseUrl || null;
playerInternalBaseUrlPromise = null;
return playerInternalBaseUrl;
if (registeredPlayers.length) {
const stalePlayer = registeredPlayers.find(function (player) {
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
}) || registeredPlayers[0] || null;
const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer);
const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url);
const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim();
playerInternalBaseUrl = staleInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: staleIdentifier || null,
playerPublicBaseUrl: stalePublicBaseUrl || null,
playerInternalBaseUrl: staleInternalBaseUrl || null,
playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null,
playerActive: false
};
return playerTaskMetadata;
}
}
} catch (_error) {
}
playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: playerInternalBaseUrl,
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
})().then(function (metadata) {
playerTaskMetadataPromise = null;
return metadata || null;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
playerTaskMetadataPromise = null;
return {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null
};
});
return playerInternalBaseUrlPromise;
return playerTaskMetadataPromise;
}
function formatPlayerTaskLabel(metadata) {
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
if (playerLabel) {
return playerLabel;
}
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
if (playerIdentifier) {
return playerIdentifier;
}
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
if (playerPublicBaseUrl) {
return playerPublicBaseUrl;
}
return '';
}
function logMediaSyncSummary(level, message, metadata, details) {
const suffix = formatPlayerTaskLabel(metadata);
const logger = level === 'warn' ? console.warn : console.info;
if (details !== undefined) {
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details);
return;
}
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`);
}
function createUploadMiddleware(uploadDir) {
@@ -278,6 +404,15 @@ function createUploadSyncService(options) {
return Boolean(localUploadDir);
}
function isPlayerUnavailableError(error) {
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
}
function isPlayerUnavailableResponse(response) {
return Boolean(response) && Number(response.status) === 503;
}
function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) {
return;
@@ -305,13 +440,13 @@ function createUploadSyncService(options) {
}, 5000);
}
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
if (!targetBaseUrl) {
return false;
}
@@ -336,7 +471,7 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer
});
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
@@ -345,23 +480,27 @@ function createUploadSyncService(options) {
body: fileBuffer
});
if (!response.ok) {
if (!isPlayerUnavailableResponse(response)) {
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
}
return false;
}
return true;
} catch (error) {
if (!isPlayerUnavailableError(error)) {
console.warn('Unable to sync upload to player:', relativePath, error);
}
return false;
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
if (!targetBaseUrl) {
return false;
}
@@ -374,7 +513,7 @@ function createUploadSyncService(options) {
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}`
});
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
@@ -382,12 +521,16 @@ function createUploadSyncService(options) {
}
});
if (!response.ok && response.status !== 404) {
if (!isPlayerUnavailableResponse(response)) {
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
}
return false;
}
return true;
} catch (error) {
if (!isPlayerUnavailableError(error)) {
console.warn('Unable to remove upload from player:', relativePath, error);
}
return false;
}
}
@@ -397,9 +540,10 @@ function createUploadSyncService(options) {
return;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
if (!success) {
queuePlayerUploadSync({
type: 'put',
@@ -552,6 +696,10 @@ function createUploadSyncService(options) {
pendingPlaylistUploadSyncs.delete(operation.key);
}
if (pendingEntries.length) {
logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata);
}
})().finally(function () {
pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) {
@@ -573,18 +721,47 @@ function createUploadSyncService(options) {
pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
? pendingEntries[0].metadata
: await getPlayerTaskMetadata();
if (playerMetadata && playerMetadata.playerActive === false) {
pendingPlayerUploadSyncs.clear();
pendingPlayerUploadSyncRetryLogAt = 0;
return;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl();
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
let success = false;
if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
} else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
}
if (success) {
successCount += 1;
pendingPlayerUploadSyncs.delete(operation.uploadPath);
} else {
failureCount += 1;
}
}
if (successCount) {
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
}
if (failureCount) {
const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now;
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
}
} else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0;
}
})().finally(function () {
pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) {
@@ -680,12 +857,14 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const playerMetadata = await getPlayerTaskMetadata();
const definition = {
key: taskKey,
title: title,
category: 'media-sync',
taskType: 'media-sync',
metadata: Object.assign({}, playerMetadata || {}),
payload: safePayload,
persist: true
};
@@ -723,7 +902,8 @@ function createUploadSyncService(options) {
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask
queueMediaSyncTask: queueMediaSyncTask,
getPlayerTaskMetadata: getPlayerTaskMetadata
};
}
+133 -28
View File
@@ -1,4 +1,52 @@
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function 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 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;
@@ -22,26 +70,38 @@ function createPlayerActionService(options) {
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
try {
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const exactPlayer = Array.isArray(players)
? players.find(function (player) {
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
})
: null;
const registeredPlayers = Array.isArray(players) ? players : [];
const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) {
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || registeredPlayers[0] || null;
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
if (resolvedBaseUrl) {
playerInternalBaseUrl = resolvedBaseUrl;
return resolvedBaseUrl;
}
}
} catch (_error) {
}
try {
const [rows] = await pool.query(
`SELECT internal_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1`
);
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
} catch (_error) {
return configuredPlayerInternalBaseUrl || null;
if (configuredPlayerInternalBaseUrl) {
playerInternalBaseUrl = configuredPlayerInternalBaseUrl;
return configuredPlayerInternalBaseUrl;
}
return null;
})().then(function (baseUrl) {
playerInternalBaseUrl = baseUrl || null;
playerInternalBaseUrlPromise = null;
return playerInternalBaseUrl;
return baseUrl || null;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
@@ -50,24 +110,26 @@ function createPlayerActionService(options) {
return playerInternalBaseUrlPromise;
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
if (!targetBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
if (connectionId) {
payload.connectionId = connectionId;
}
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
body: payload
});
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -89,6 +151,11 @@ function createPlayerActionService(options) {
});
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
}
async function forwardAnnouncementRefresh(slug) {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
@@ -127,12 +194,24 @@ function createPlayerActionService(options) {
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) {
if (resolvedPlayerInternalBaseUrl) {
targetBaseUrls.push(resolvedPlayerInternalBaseUrl);
}
}
if (!targetBaseUrls.length) {
throw new Error('Unable to resolve the player internal base URL.');
}
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
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',
@@ -141,15 +220,40 @@ function createPlayerActionService(options) {
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
error.statusCode = response.status;
throw error;
return null;
}
return response.json().catch(function () {
return { connections: [] };
return null;
});
}));
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;
}
});
return {
screen: screen,
screenSlug: slug,
count: mergedConnections.length,
connections: mergedConnections,
degraded: degraded
};
}
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
@@ -197,6 +301,7 @@ function createPlayerActionService(options) {
forwardPlayerCommand: forwardPlayerCommand,
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
+3 -2
View File
@@ -694,7 +694,8 @@
.table-pagination-page .app-content {
height: 100%;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
}
.table-pagination-page .app-content .container-fluid {
@@ -730,7 +731,7 @@
display: flex;
flex: 0 1 auto;
flex-direction: column;
min-height: 0;
min-height: var(--table-pagination-card-min-height, 0);
max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem)));
overflow: hidden;
}
+392 -90
View File
@@ -6,7 +6,169 @@
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
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');
var container = table && typeof table.closest === 'function'
? (table.closest('[data-table-search-container]') || table.closest('.card') || null)
: null;
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
}
function getClientListQueryState() {
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
var searchInput = getClientSearchInput();
var searchValue = searchInput ? String(searchInput.value || '').trim() : String(searchParams.get('search') || '').trim();
return {
search: searchValue,
sort: String(searchParams.get('sort') || '').trim(),
direction: String(searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc',
page: Math.max(1, Math.floor(Number(searchParams.get('page') || 1) || 1))
};
}
function isClientSearchLoading() {
return Boolean(document.querySelector('[data-table-search-loading="true"]'));
}
function getComparableClientSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue)) {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareClientSortValues(leftValue, rightValue) {
var left = getComparableClientSortValue(leftValue);
var right = getComparableClientSortValue(rightValue);
if (left.type === 'empty' && right.type === 'empty') {
return 0;
}
if (left.type === 'empty') {
return 1;
}
if (right.type === 'empty') {
return -1;
}
if (left.type === right.type) {
if (left.value < right.value) {
return -1;
}
if (left.value > right.value) {
return 1;
}
return 0;
}
return String(left.value).localeCompare(String(right.value), undefined, { numeric: true, sensitivity: 'base' });
}
function createClientSearchMatcher(searchTerm) {
var query = String(searchTerm || '').trim().toLowerCase();
if (!query) {
return function () {
return true;
};
}
return function (client) {
var searchableValues = [
client && client.name,
client && client.client_name,
client && client.clientId,
client && client.deviceId,
client && client.slug,
client && client.screen_slug,
client && client.screen_name,
client && client.ipAddress,
client && client.clientIp,
client && client.status,
client && client.currentSlideTitle
];
return searchableValues.map(function (value) {
return String(value || '').trim().toLowerCase();
}).join(' ').indexOf(query) !== -1;
};
}
function sortClientsForTable(clients, sortKey, sortDirection) {
var normalizedSortKey = String(sortKey || '').trim();
var normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
var accessors = {
client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) {
var viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) {
return '';
}
return String(Number(viewport.width) || 0) + 'x' + String(Number(viewport.height) || 0);
},
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
function compareValues(leftValue, rightValue) {
return compareClientSortValues(leftValue, rightValue);
}
var sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
if (sortKeys[0] === 'client') {
sortKeys.push('ip');
} else if (sortKeys[0] === 'ip') {
sortKeys.push('client');
}
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (var index = 0; index < sortKeys.length; index += 1) {
var sortKeyName = sortKeys[index];
var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
}
}
return 0;
});
}
function getVisibleClients(state) {
var query = getClientListQueryState();
var clients = Array.isArray(state && state.clients) ? state.clients.slice() : [];
var searchMatcher = createClientSearchMatcher(query.search);
clients = clients.filter(searchMatcher);
clients = sortClientsForTable(clients, query.sort, query.direction);
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
}
function getClientMoveModalElements() {
return {
@@ -15,7 +177,8 @@
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]')
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
}
@@ -48,6 +211,7 @@
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 || []);
@@ -69,6 +233,9 @@
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;
@@ -89,12 +256,12 @@
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) + '" /><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>',
'<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>',
'<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) + '" /><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) + '" /><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) + '" /><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>',
'<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="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</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="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>',
'<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="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
'</div>'
].join('');
}
@@ -126,6 +293,10 @@
if (connectionInput) {
connectionInput.value = client.id || '';
}
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
if (playerBaseUrlInput) {
playerBaseUrlInput.value = client.player_url || '';
}
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
@@ -141,6 +312,10 @@
if (reloadInput) {
reloadInput.value = client.id || '';
}
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
if (reloadPlayerBaseUrlInput) {
reloadPlayerBaseUrlInput.value = client.player_url || '';
}
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
}
@@ -173,6 +348,10 @@
if (blackoutConnectionInput) {
blackoutConnectionInput.value = client.id || '';
}
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
if (blackoutPlayerBaseUrlInput) {
blackoutPlayerBaseUrlInput.value = client.player_url || '';
}
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
@@ -194,6 +373,10 @@
if (previousConnectionInput) {
previousConnectionInput.value = client.id || '';
}
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
if (previousPlayerBaseUrlInput) {
previousPlayerBaseUrlInput.value = client.player_url || '';
}
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
@@ -215,6 +398,10 @@
if (nextConnectionInput) {
nextConnectionInput.value = client.id || '';
}
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
if (nextPlayerBaseUrlInput) {
nextPlayerBaseUrlInput.value = client.player_url || '';
}
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
}
@@ -253,7 +440,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 || '') + '">',
'<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 || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>',
@@ -265,26 +452,67 @@
].join('');
}
function updateClientRowCells(row, client, hasActionsColumn) {
if (!row || !row.cells || row.cells.length < 6) {
return;
}
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
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-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 || ''));
setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
setCellHtml(row.cells[2], currentSlide);
setCellHtml(row.cells[3], clientIp);
setCellHtml(row.cells[4], viewport);
setCellHtml(row.cells[5], connectedAt);
syncClientActionCell(row, client, hasActionsColumn);
}
function createClientRowFromTemplate(client, hasActionsColumn) {
var template = document.createElement('tbody');
template.innerHTML = renderClientRow(client, hasActionsColumn);
return template.firstElementChild || null;
}
function setCellHtml(cell, html) {
if (!cell || String(cell.innerHTML || '') === String(html || '')) {
return false;
}
cell.innerHTML = html;
return true;
}
function renderScreenTile(screen) {
var clientCount = Number(screen.player_connection_count || 0);
var playerUrl = String(screen.player_url || '').trim();
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
return [
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
'<div class="dashboard-screen-tile-top">',
'<div class="dashboard-screen-tile-text">',
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
'</div>',
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
'</div>',
'<dl class="dashboard-screen-meta">',
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
'</dl>',
'</article>'
].join('');
@@ -310,7 +538,7 @@
}
function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count');
var playerCount = document.getElementById('dashboard-player-count');
var screenCount = document.getElementById('dashboard-screen-count');
var slideCount = document.getElementById('dashboard-slide-count');
var playlistCount = document.getElementById('dashboard-playlist-count');
@@ -324,81 +552,137 @@
if (screenCount && Array.isArray(state.screens)) {
screenCount.textContent = String(state.screens.length);
}
if (clientCount) {
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
if (playerCount) {
playerCount.textContent = String(Number(state.connectedPlayersCount || 0));
}
}
function updateClientTable(state) {
function updateClientTable(state, forceRender) {
var tbody = document.getElementById('dashboard-clients-table-body');
if (!tbody || !Array.isArray(state.clients)) {
return;
}
if (!forceRender && isClientSearchLoading()) {
return;
}
var table = document.getElementById('dashboard-clients-table');
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
if (!state.clients.length) {
var visibleClients = getVisibleClients(state);
var canPatchRows = typeof tbody.querySelectorAll === 'function'
&& typeof tbody.insertBefore === 'function'
&& typeof tbody.removeChild === 'function'
&& typeof document.createElement === 'function';
if (!visibleClients.length) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
var existingRows = {};
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
existingRows[row.getAttribute('data-client-key')] = row;
});
Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) {
if (!row.hasAttribute('data-client-key')) {
row.parentNode.removeChild(row);
}
});
state.clients.forEach(function (client, index) {
var rowKey = getClientRowKey(client);
var row = existingRows[rowKey];
if (!row) {
var tempBody = document.createElement('tbody');
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
row = tempBody.firstElementChild;
}
if (!row) {
if (!canPatchRows) {
tbody.innerHTML = visibleClients.map(function (client) {
return renderClientRow(client, hasActionsColumn);
}).join('');
return;
}
row.setAttribute('data-client-key', rowKey);
row.setAttribute('data-client-id', client.clientId || '');
row.setAttribute('data-client-device-id', client.deviceId || '');
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
if (row.cells && row.cells.length >= 6) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
var existingRows = {};
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row;
});
row.cells[0].innerHTML = '<div>' + clientName + '</div>';
row.cells[1].innerHTML = '<div>' + screenName + '</div>';
row.cells[2].innerHTML = currentSlide;
row.cells[3].innerHTML = clientIp;
row.cells[4].innerHTML = viewport;
row.cells[5].innerHTML = connectedAt;
syncClientActionCell(row, client, hasActionsColumn);
var nextRows = visibleClients.map(function (client) {
var rowKey = String(getClientRowKey(client) || '').trim();
var row = existingRows[rowKey] || null;
if (!row) {
row = createClientRowFromTemplate(client, hasActionsColumn);
} else {
updateClientRowCells(row, client, hasActionsColumn);
}
return row;
}).filter(function (row) {
return Boolean(row);
});
nextRows.forEach(function (row, index) {
var referenceNode = tbody.children[index] || null;
if (referenceNode !== row) {
tbody.insertBefore(row, referenceNode);
}
});
while (tbody.children.length > state.clients.length) {
while (tbody.children.length > nextRows.length) {
tbody.removeChild(tbody.lastElementChild);
}
}
function refreshClientTableFromLatestState() {
if (!latestDashboardState) {
return;
}
updateClientTable(latestDashboardState, true);
}
function updateKioskLauncherModal(state) {
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
if (!modal) {
return;
}
var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]');
var select = modal.querySelector('[data-kiosk-launcher-player-select]');
var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]'));
var playersState = state && Array.isArray(state.kioskPlayers)
? state.kioskPlayers
: (state && Array.isArray(state.clients) ? state.clients : []);
var players = playersState.filter(function (client) {
return Boolean(client && String(client.player_url || '').trim());
});
if (select && playersState.length) {
var currentValue = String(select.value || '').trim();
var options = ['<option value="">Select a player</option>'];
players.forEach(function (player) {
var playerUrl = String(player.player_url || '').trim();
var playerIdentifier = String(player.player_identifier || 'Connected player').trim();
if (!playerUrl) {
return;
}
options.push('<option value="' + escapeHtml(playerUrl) + '" data-player-identifier="' + escapeHtml(playerIdentifier) + '">' + escapeHtml(playerIdentifier + ' - ' + playerUrl) + '</option>');
});
select.innerHTML = options.join('');
if (currentValue) {
select.value = currentValue;
}
}
var selectedUrl = select ? String(select.value || '').trim() : '';
var canEnableDownloads = Boolean(checkbox && checkbox.checked && selectedUrl);
downloadLinks.forEach(function (link) {
if (!link) {
return;
}
var baseHref = String(link.getAttribute('data-kiosk-launcher-download-base') || link.getAttribute('href') || '').trim();
if (canEnableDownloads) {
link.setAttribute('href', baseHref + '?playerUrl=' + encodeURIComponent(selectedUrl));
link.classList.remove('disabled');
link.setAttribute('aria-disabled', 'false');
link.removeAttribute('tabindex');
} else {
link.removeAttribute('href');
link.classList.add('disabled');
link.setAttribute('aria-disabled', 'true');
link.setAttribute('tabindex', '-1');
}
});
}
function updateScreenGrid(state) {
var grid = document.getElementById('dashboard-screens-grid');
if (!grid || !Array.isArray(state.screens)) {
@@ -455,13 +739,22 @@
}
select.disabled = false;
if (!select.value || !screenBySlug[select.value]) {
select.value = screens[0].slug || '';
}
var selectedScreen = screenBySlug[select.value] || screens[0];
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG;
var selectedScreen = isAllSelected
? {
slug: ALL_SCREENS_SLUG,
name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL
}
: screenBySlug[select.value] || null;
var selectedSlug = isAllSelected
? ALL_SCREENS_SLUG
: String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
if (isAllSelected) {
return true;
}
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
@@ -480,12 +773,20 @@
pill.textContent = connectionLabel;
}
if (nameNode) {
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
nameNode.textContent = selectedScreen
? String(selectedScreen.name || 'Selected screen')
: 'Select a target screen group';
}
if (metaNode) {
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
metaNode.textContent = !selectedSlug
? 'Choose a screen group before sending commands.'
: isAllSelected
? 'Commands sent here target every client across every screen group.'
: 'Commands sent here target every client currently using this screen.';
}
var commandTargetSlug = selectedSlug || '';
forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
@@ -498,10 +799,12 @@
pauseStateInput.value = allPaused ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen'));
}
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
form.setAttribute('data-confirm-message', allPaused
? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?')
: (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?'));
} else if (command === 'blackout') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
@@ -509,23 +812,25 @@
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen'));
}
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
form.setAttribute('data-confirm-message', allBlackout
? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?')
: (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?'));
} else {
commandInput.value = command || commandInput.value || '';
}
}
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
if (command === 'reload') {
form.setAttribute('data-confirm-message', 'Reload selected screen?');
form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?');
if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
}
}
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !selectedSlug;
control.disabled = !commandTargetSlug;
});
});
}
@@ -599,10 +904,12 @@
return;
}
latestDashboardState = state;
window.webLatestDashboardState = latestDashboardState;
updateStats(state);
updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state);
updateKioskLauncherModal(state);
updateDashboardQuickActions(state);
}
@@ -780,34 +1087,28 @@
}
var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]');
var select = modal.querySelector('[data-kiosk-launcher-player-select]');
var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]'));
function setDownloadsEnabled(enabled) {
downloadLinks.forEach(function (link) {
if (!link) {
return;
}
link.classList.toggle('disabled', !enabled);
link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
if (enabled) {
link.removeAttribute('tabindex');
} else {
link.setAttribute('tabindex', '-1');
}
});
}
function resetModalState() {
if (checkbox) {
checkbox.checked = false;
}
setDownloadsEnabled(false);
if (select) {
select.value = '';
}
updateKioskLauncherModal(latestDashboardState);
}
if (checkbox) {
checkbox.addEventListener('change', function () {
setDownloadsEnabled(Boolean(checkbox.checked));
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
});
}
if (select) {
select.addEventListener('change', function () {
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
});
}
@@ -825,6 +1126,7 @@
}
window.webHandleDashboardState = handleDashboardState;
window.webRefreshClientTableFromLatestState = refreshClientTableFromLatestState;
initClientRenameHandler();
initClientMoveHandler();
@@ -313,6 +313,7 @@
'<div>' +
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>';
+23 -1
View File
@@ -1,4 +1,6 @@
(function () {
var minimumPaginationCardHeight = 20 * 16;
function rebindTableContainer(container) {
if (!container) {
return;
@@ -33,8 +35,18 @@
var cardRect = card.getBoundingClientRect();
var bottomInset = 16;
var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset);
var contentHeight = Math.max(0, card.scrollHeight || 0);
var shouldApplyMinimum = contentHeight > minimumPaginationCardHeight;
var cardHeight = Math.max(minimumPaginationCardHeight, availableHeight);
if (shouldApplyMinimum) {
card.style.setProperty('--table-pagination-card-min-height', minimumPaginationCardHeight + 'px');
} else {
card.style.removeProperty('--table-pagination-card-min-height');
}
card.style.removeProperty('--table-pagination-card-height');
card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px');
card.style.setProperty('--table-pagination-card-max-height', cardHeight + 'px');
});
}
@@ -112,6 +124,7 @@
var currentUrl = new URL(window.location.href);
var pendingSearchTimer = null;
var requestSequence = 0;
var pendingSearchRequests = 0;
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
@@ -133,6 +146,10 @@
requestSequence += 1;
var sequenceId = requestSequence;
pendingSearchRequests += 1;
if (container) {
container.setAttribute('data-table-search-loading', 'true');
}
fetch(nextUrl.toString(), {
method: 'GET',
@@ -165,6 +182,11 @@
}
}).catch(function () {
window.location.assign(nextUrl.toString());
}).finally(function () {
pendingSearchRequests = Math.max(0, pendingSearchRequests - 1);
if (container && pendingSearchRequests === 0) {
container.removeAttribute('data-table-search-loading');
}
});
}
+162 -7
View File
@@ -6,17 +6,82 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
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) {
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)));
} catch (_error) {
return [];
}
}
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
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 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 blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
@@ -28,11 +93,82 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Command is required' });
}
if (slug === ALL_SCREENS_SLUG) {
if (command === 'setclientname' || command === 'moveclient') {
return res.status(400).json({ error: 'This command requires a specific screen.' });
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC');
if (!screenRows.length) {
return res.status(404).json({ error: 'No screens found' });
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
await Promise.all(screenRows.map(function (screenRow) {
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);
}));
}
return forwardPlayerCommand(screenSlug, commandPayload);
});
}));
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: {
id: null,
name: 'All screens',
slug: ALL_SCREENS_SLUG
},
screenSlug: slug,
command: command,
connectionId: connectionId || null,
targetScreenCount: screenRows.length,
ok: true,
allScreens: true
});
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
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();
@@ -208,20 +344,32 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
const liveConnection = Array.isArray(liveConnections)
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
})
: null;
const targetBaseUrl = String(
targetPlayerRecord && targetPlayerRecord.public_base_url ||
const sourcePlayerBaseUrl = String(
explicitPlayerBaseUrl ||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
} else {
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
}
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
@@ -247,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
commandPayload.blackout = blackoutValue;
}
const result = connectionId
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);
: await forwardPlayerCommand(slug, commandPayload));
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
+19
View File
@@ -15,6 +15,8 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
@@ -22,6 +24,22 @@ function registerRoutes(app, deps) {
registerAuthAndAccountRoutes(app, deps);
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerInternalSyncRoutes(app, {
uploadSyncService: deps.uploadSyncService,
mediaDir: deps.mediaDir
});
registerScreensRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
mediaDir: deps.mediaDir,
formatDashboardDate: deps.formatDashboardDate,
buildDashboardState: deps.buildDashboardState,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
requirePermission: deps.requirePermission,
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
});
}
function registerAuthAndAccountRoutes(app, deps) {
@@ -124,6 +142,7 @@ function registerSignageRoutes(app, deps) {
pool: deps.pool,
common: deps.common,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable,
withClientNameReservation: deps.withClientNameReservation,
+25 -6
View File
@@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
@@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) {
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
if (!accessors[normalizedSortKey]) {
return Array.isArray(clients) ? clients.slice() : [];
function compareValues(leftValue, rightValue) {
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
}
return sortRows(clients, function (client) {
return accessors[normalizedSortKey](client);
}, normalizedDirection);
const sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
if (sortKeys[0] === 'client') {
sortKeys.push('ip');
} else if (sortKeys[0] === 'ip') {
sortKeys.push('client');
}
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (let index = 0; index < sortKeys.length; index += 1) {
const sortKeyName = sortKeys[index];
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
}
}
return 0;
});
}
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
@@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
const duration = videoRegions.reduce((longest, region) => {
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
return regionDuration > longest ? regionDuration : longest;
}, 0);
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
+49 -20
View File
@@ -1,6 +1,7 @@
// Screen route registration and dashboard wiring.
const fs = require('fs');
const { screenPlayerUrl } = require('../../../routes/common');
module.exports = function registerScreensRoutes(app, deps) {
const pool = deps.pool;
@@ -11,6 +12,14 @@ module.exports = function registerScreensRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
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;
}
const LIST_PAGE_SIZE = 25;
const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat');
const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh');
@@ -19,6 +28,17 @@ module.exports = function registerScreensRoutes(app, deps) {
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
};
function normalizeTargetPlayerUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return null;
}
if (!/^https?:\/\//i.test(normalized)) {
return null;
}
return normalized;
}
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
@@ -58,7 +78,6 @@ module.exports = function registerScreensRoutes(app, deps) {
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount,
player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null
});
});
}
@@ -71,6 +90,26 @@ module.exports = function registerScreensRoutes(app, deps) {
return common.fetchPlayerPublicBaseUrl(pool);
}
async function buildScreenPlayerUrls(screen) {
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
? await common.fetchPlayerRegistrations(pool)
: [];
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) {
return isRecentPlayerRegistration(player, 60);
}).map(function (player) {
const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl);
return {
identifier: String(player && player.identifier || '').trim(),
public_base_url: baseUrl || null,
player_url: playerUrl || null
};
}).sort(function (left, right) {
return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true });
});
}
function buildLauncherContent(templatePath, playerUrl) {
const template = fs.readFileSync(templatePath, 'utf8');
const normalizedPlayerUrl = String(playerUrl || '').trim();
@@ -100,8 +139,8 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.player_url = await buildPlayerUrl();
if (screen.player_url) {
screen.player_urls = await buildScreenPlayerUrls(screen);
if (screen.player_urls.length) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
@@ -113,18 +152,9 @@ module.exports = function registerScreensRoutes(app, deps) {
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : '';
return Object.assign({}, screen, {
player_url: screen.player_url || registryUrl || null
});
}),
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -138,11 +168,8 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
screen.player_url = String(playerUrlsBySlug[screen.slug] || '').trim() || null;
if (screen.player_url) {
screen.player_urls = await buildScreenPlayerUrls(screen);
if (screen.player_urls.length) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
@@ -155,7 +182,8 @@ module.exports = function registerScreensRoutes(app, deps) {
app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|| await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
@@ -173,7 +201,8 @@ module.exports = function registerScreensRoutes(app, deps) {
app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|| await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
+8 -2
View File
@@ -95,7 +95,7 @@
<tbody id="dashboard-clients-table-body">
{{#if clients.length}}
{{#each clients}}
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
</td>
@@ -127,33 +127,38 @@
<span class="empty">Unknown</span>
{{/if}}
</td>
{{#if (hasPermission currentUser 'clients.allow')}}
{{#if (hasPermission ../currentUser 'clients.allow')}}
<td data-label="Actions" class="text-end">
<div class="actions justify-content-end">
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
<input type="hidden" name="command" value="reload" />
<input type="hidden" name="connectionId" value="{{id}}" />
<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>
<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}}" />
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
<button type="submit" class="btn btn-sm btn-warning" 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/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="next" />
<input type="hidden" name="connectionId" value="{{id}}" />
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
<button type="submit" class="btn btn-sm btn-warning" 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/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="connectionId" value="{{id}}" />
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
<button type="submit" class="btn btn-sm btn-info" aria-label="Pause client" title="Pause client"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause</button>
</form>
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="connectionId" value="{{id}}" />
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}" aria-label="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}" title="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}"><i class="bi {{#if blackout}}bi-eye{{else}}bi-eye-slash{{/if}} me-1" aria-hidden="true"></i>{{#if blackout}}Restore{{else}}Blackout{{/if}}</button>
</form>
</div>
@@ -183,6 +188,7 @@
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
<input type="hidden" name="clientName" value="" data-client-move-client-name />
<input type="hidden" name="playerBaseUrl" value="" data-client-move-player-base-url />
<label class="form-label" for="client-move-screen-target">Target screen group</label>
<select class="form-select" id="client-move-screen-target" name="targetScreenSlug" data-client-move-target required>
{{#if screens.length}}
+28 -7
View File
@@ -6,7 +6,7 @@
</div>
<div class="row g-4">
<div class="col-12 col-xl-8">
<div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card h-100">
<div class="card-header">
<h3 class="card-title">Screen group details</h3>
@@ -40,16 +40,37 @@
</div>
</div>
<div class="col-12 col-xl-4">
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card h-100">
<div class="card-header">
<h3 class="card-title">Player URL</h3>
<h3 class="card-title">Player URLs</h3>
</div>
<div class="card-body">
{{#if screen.player_url}}
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
<div class="card-body table-responsive p-0">
{{#if screen.player_urls.length}}
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Player</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{{#each screen.player_urls}}
<tr>
<td data-label="Player" class="text-break">{{identifier}}</td>
<td class="text-break">
{{#if player_url}}
<a href="{{player_url}}" target="_blank" rel="noreferrer">{{player_url}}</a>
{{else}}
<p class="mb-0 text-muted">No player URL is available yet.</p>
<span class="text-muted">Not available</span>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p class="mb-0 p-3 text-muted">No player URLs are available yet.</p>
{{/if}}
</div>
</div>
+470
View File
@@ -0,0 +1,470 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
function createHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('move client rebinding redirects the live player to the target screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'source-screen' }, { slug: 'target-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
}
if (sql.includes('INSERT INTO d_onboarding_devices')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://remote-player.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'moveclient',
deviceId: 'device-123',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
connectionId: 'conn-1',
playerBaseUrl: 'http://remote-player.example'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.targetScreenSlug, 'target-screen');
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
getScreenConnections: async () => ({ connections: [] }),
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: {
command: 'pause',
paused: 'true'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('screen control commands use the live connection player url when available', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
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' }]];
}
return [[]];
}
},
common: {},
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 {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'pause',
connectionId: 'conn-1'
},
query: {}
}, response, () => {});
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);
});
test('screen control commands fan out to every live player for the selected screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
return [[]];
}
},
common: {},
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 {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player-a.example'
},
{
id: 'conn-2',
clientId: 'conn-2',
deviceId: 'device-456',
playerPublicBaseUrl: 'http://local-player-b.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'pause'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://local-player-a.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 () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
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 () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: {
command: 'pause'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.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']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerClientsRoutes = require('../src/web/routes/signage/clients/routes');
const renderConnectedClientsPage = require('../src/web/routes/signage/clients/list');
function createHandlers() {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('clients list defaults to client then ip ordering', async () => {
const { app, handlers } = createHandlers();
registerClientsRoutes(app, {
pool: {},
common: {
getSearchQuery() {
return '';
},
getSortQuery() {
return '';
},
getSortDirectionQuery() {
return 'asc';
}
},
pages: {
renderConnectedClientsPage(data) {
return JSON.stringify(data.clients.map(function (client) {
return {
client_name: client.client_name,
clientIp: client.clientIp
};
}));
}
},
buildDashboardState: async () => ({
screens: [],
clients: [
{ client_name: 'Beta', clientIp: '10.0.0.9' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Alpha', clientIp: '10.0.0.2' }
]
}),
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
send(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
query: {},
currentUser: { id: 1 }
}, response, () => {});
assert.deepEqual(JSON.parse(response.body), [
{ client_name: 'Alpha', clientIp: '10.0.0.2' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Beta', clientIp: '10.0.0.9' }
]);
});
test('clients page renders action cells for clients with permission', () => {
const html = renderConnectedClientsPage(
{
screens: [],
clients: [
{
id: 'row-1',
clientId: 'client-1',
screen_slug: 'demo-lobby',
screen_name: 'Demo Lobby Screen',
client_name: 'Local Test',
clientIp: '192.168.0.1',
player_url: 'http://player.local',
connectedAt: new Date('2026-08-07T20:00:00Z'),
connectedAtLabel: 'Aug 07, 2026, 8:00:00 PM'
}
],
pagination: null
},
'',
{ id: 1, permissions: ['clients.read', 'clients.allow'] }
);
assert.match(html, /data-label="Actions"/);
assert.match(html, /Pause client/);
assert.match(html, /Blackout client/);
});
+126
View File
@@ -0,0 +1,126 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createDashboardStateService } = require('../src/web/lib/dashboard-state');
test('dashboard state counts active players from the registry heartbeat', async () => {
const service = createDashboardStateService({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 6 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
return {
screens: [
{
slug: 'alpha',
name: 'Alpha',
playlist_name: 'Main Loop'
}
],
playlists: [],
slides: []
};
},
async fetchScreenPlayerUrls() {
return {
alpha: 'http://player.local/screen/alpha'
};
},
async fetchPlayerRegistrations() {
return [
{
identifier: 'player-alpha',
public_base_url: 'http://player.local',
last_seen_at: new Date(Date.now() - 10 * 1000)
},
{
identifier: 'player-stale',
public_base_url: 'http://stale.player.local',
last_seen_at: new Date(Date.now() - 10 * 60 * 1000)
}
];
}
},
playerSnapshotCache: new Map([
['alpha', { count: 1, connections: [{ id: 1, playerPublicBaseUrl: 'http://player.local' }] }]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription() {},
formatDashboardDate(value) {
return String(value || '');
}
});
const state = await service.buildDashboardState();
assert.equal(state.connectedPlayersCount, 6);
assert.equal(state.connectedClientsCount, 1);
assert.equal(state.clients[0].player_identifier, 'player-alpha');
assert.equal(state.kioskPlayers.length, 1);
assert.deepEqual(state.kioskPlayers[0], {
player_identifier: 'player-alpha',
player_url: 'http://player.local'
});
});
test('dashboard state keeps other screens when one subscription fails', async () => {
const service = createDashboardStateService({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 0 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
return {
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
playlists: [],
slides: []
};
},
async fetchScreenPlayerUrls() {
return {};
},
async fetchPlayerRegistrations() {
return [];
}
},
playerSnapshotCache: new Map([
['beta', { count: 1, connections: [{ id: 'beta-conn', clientId: 'beta-conn' }] }]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription(slug) {
if (slug === 'alpha') {
throw new Error('subscription failed');
}
},
formatDashboardDate(value) {
return String(value || '');
}
});
const state = await service.buildDashboardState();
assert.equal(state.screens.length, 2);
assert.equal(state.clients.length, 1);
assert.equal(state.clients[0].screen_slug, 'beta');
});
+134
View File
@@ -0,0 +1,134 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
headers: {
get() {
return null;
}
},
async json() {
return { ok: true };
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
}
});
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
process.env.PLAYER_IDENTIFIER = 'player-local';
try {
const response = await playerActionService.forwardPlayerCommand('demo', { command: 'refresh' });
assert.deepEqual(response, { ok: true });
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://player:8081/api/screens/demo/commands');
} finally {
process.env.PLAYER_IDENTIFIER = originalIdentifier;
global.fetch = originalFetch;
}
});
test('player actions merge screen connections from every recent player registration', 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: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
}
return {
ok: true,
status: 200,
headers: { get() { return null; } },
async json() {
return {
screenSlug: 'demo',
connections: [
{ id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
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()
}
]];
}
}
});
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);
} finally {
global.fetch = originalFetch;
}
});
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
delete process.env.WEB_BASE_URL;
} else {
process.env.WEB_BASE_URL = originalWebBaseUrl;
}
});
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://web.example.test/app');
});
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
delete process.env.WEB_BASE_URL;
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://bridge.example.test');
});
test('resolveScreenCommandTargets only returns open sockets for the requested screen', () => {
const playerSockets = new Map([
['player-a', { readyState: 1 }],
['player-b', { readyState: 3 }],
['player-c', { readyState: 1 }]
]);
const screenPlayerDeviceIds = new Map([
['demo-conference', ['player-a', 'player-b', 'player-a', 'player-missing']],
['other-screen', ['player-c']]
]);
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
+3 -2
View File
@@ -56,7 +56,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
@@ -123,9 +123,10 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
assert.equal(payload.screen.slug, 'test2');
assert.equal(payload.playlist.name, 'Playlist 22');
assert.equal(payload.slides[0].duration_seconds, 12.346);
assert.equal(payload.slides[0].duration_seconds, 20.111);
assert.equal(payload.slides[0].disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(payload.slides[1].disable_audio, true);
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
+29
View File
@@ -55,3 +55,32 @@ test('playlist rows show mute controls for RTMP slides', () => {
assert.equal(model.playlistSlides[1].showMuteButton, true);
assert.equal(model.playlistSlides[2].showMuteButton, false);
});
test('playlist rows use the longest video region duration', () => {
const model = buildPlaylistFormViewModel(
{ id: 23, name: 'Playlist 23' },
{
playlistSlides: [
{
id: 4,
playlist_id: 23,
slide_id: 104,
position: 1,
title: 'Multi-video slide',
content_json: '{"firstVideo":{"type":"video","value":"/media/first.mp4","duration_seconds":12.5},"secondVideo":{"type":"video","value":"/media/second.mp4","duration_seconds":27.75},"textRegion":{"type":"text","value":"Hello"}}',
duration_seconds: 10,
use_video_duration: 1,
disable_audio: 1
}
],
slides: []
},
'',
null,
{ isEdit: true }
);
assert.equal(model.playlistSlides[0].showVideoDurationButton, true);
assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75);
assert.equal(model.playlistSlides[0].durationSeconds, 27.75);
});
+133
View File
@@ -0,0 +1,133 @@
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');
test('table pagination cards skip the minimum height for short content', () => {
const cardStyle = {
properties: {},
setProperty(name, value) {
this.properties[name] = String(value);
},
removeProperty(name) {
delete this.properties[name];
}
};
const card = {
style: cardStyle,
scrollHeight: 200,
getBoundingClientRect() {
return { top: 100 };
}
};
const bodyClassList = {
toggled: null,
toggle(name, value) {
this.toggled = { name, value };
}
};
const sandbox = {
document: {
body: {
classList: bodyClassList
},
querySelector(selector) {
return selector === '[data-table-pagination-card]' ? card : null;
},
querySelectorAll(selector) {
return selector === '[data-table-pagination-card]' ? [card] : [];
}
},
window: null,
URL,
console,
setTimeout,
clearTimeout
};
sandbox.window = Object.assign(sandbox, {
innerHeight: 280,
addEventListener() {},
clearTimeout,
setTimeout,
requestAnimationFrame(callback) {
callback();
}
});
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], undefined);
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
});
test('table pagination cards keep the minimum height for tall content', () => {
const cardStyle = {
properties: {},
setProperty(name, value) {
this.properties[name] = String(value);
},
removeProperty(name) {
delete this.properties[name];
}
};
const card = {
style: cardStyle,
scrollHeight: 640,
getBoundingClientRect() {
return { top: 100 };
}
};
const bodyClassList = {
toggled: null,
toggle(name, value) {
this.toggled = { name, value };
}
};
const sandbox = {
document: {
body: {
classList: bodyClassList
},
querySelector(selector) {
return selector === '[data-table-pagination-card]' ? card : null;
},
querySelectorAll(selector) {
return selector === '[data-table-pagination-card]' ? [card] : [];
}
},
window: null,
URL,
console,
setTimeout,
clearTimeout
};
sandbox.window = Object.assign(sandbox, {
innerHeight: 280,
addEventListener() {},
clearTimeout,
setTimeout,
requestAnimationFrame(callback) {
callback();
}
});
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], '320px');
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
});
+229
View File
@@ -46,3 +46,232 @@ test('multipart uploads accept large text fields used by slide and template form
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('fqdn player registration wins over a local configured player target for media sync', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081',
last_seen_at: activeLastSeenAt
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync retry warnings include the resolved player label', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
const error = new Error('getaddrinfo ENOTFOUND player-remote');
error.code = 'ENOTFOUND';
throw error;
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync treats 503 responses as unavailable without logging a per-upload warning', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-503-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
assert.ok(warned.every(function (message) {
return !message.includes('Unable to sync upload to player: sample.bin 503 Service Unavailable');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('stale player registrations stop media sync retries and warnings', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-stale-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const originalFetch = global.fetch;
const originalWarn = console.warn;
const fetchCalls = [];
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString()
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.equal(fetchCalls.length, 0);
assert.equal(warned.length, 0);
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
+87
View File
@@ -0,0 +1,87 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createWebBootstrap } = require('../src/web/bootstrap');
test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => {
let adminDataCalls = 0;
const bootstrap = createWebBootstrap({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 1 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
adminDataCalls += 1;
if (adminDataCalls === 1) {
return {
screens: [
{
slug: 'alpha',
name: 'Alpha',
playlist_name: 'Main Loop'
}
],
playlists: [],
slides: []
};
}
throw new Error('dashboard refresh failed');
},
async fetchScreenPlayerUrls() {
return {
alpha: 'http://player.local/screen/alpha'
};
},
async fetchPlayerRegistrations() {
return [
{
identifier: 'player-alpha',
public_base_url: 'http://player.local',
last_seen_at: new Date()
}
];
}
},
playerSnapshotCache: new Map([
['alpha', {
count: 1,
connections: [
{
id: 'conn-1',
playerPublicBaseUrl: 'http://player.local'
}
]
}]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription() {},
formatDashboardDate(value) {
return String(value || '');
},
notifyPlayerScreens() {},
backgroundTaskQueue: null,
uploadDir: 'media/uploads',
playerInternalBaseUrl: 'http://player.local'
});
const firstState = await bootstrap.broadcastDashboardState();
const secondState = await bootstrap.broadcastDashboardState();
assert.equal(firstState.screens[0].slug, 'alpha');
assert.equal(firstState.clients.length, 1);
assert.equal(secondState.screens[0].slug, 'alpha');
assert.equal(secondState.clients.length, 1);
assert.equal(adminDataCalls, 2);
});
+726 -2
View File
@@ -1,5 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
require('../src/common');
@@ -16,12 +18,734 @@ test('dashboard onboarding link uses the player base url', () => {
playlists: [],
clients: [],
slides: [],
connectedClientsCount: 0
connectedClientsCount: 0,
connectedPlayersCount: 0
},
'',
null
{ id: 1, permissions: ['screens.read', 'clients.read'] }
);
assert.match(html, /href="http:\/\/player\.local"/);
assert.doesNotMatch(html, /http:\/\/player\.local\//);
});
test('dashboard screen snapshot omits player links and duplicate connection counts', () => {
const html = renderDashboardPage(
{
screens: [
{
id: 7,
name: 'Demo Conference',
slug: 'demo-conference',
playlist_name: 'Main Loop',
player_connection_count: 3,
public_base_url: 'http://player.local/'
}
],
playlists: [],
clients: [],
slides: [],
connectedClientsCount: 3,
connectedPlayersCount: 3
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read'] }
);
assert.match(html, /Screen group snapshot/);
assert.match(html, /players/);
assert.doesNotMatch(html, /dashboard-screen-link/);
assert.doesNotMatch(html, /Connections<\/dt>/);
assert.match(html, /3\s+live/);
assert.doesNotMatch(html, />3 connected/);
});
test('dashboard kiosk launcher includes connected player choices', () => {
const html = renderDashboardPage(
{
screens: [],
playlists: [],
kioskPlayers: [
{
player_identifier: 'player-alpha',
player_url: 'http://player-a.example'
},
{
player_identifier: 'player-beta',
player_url: 'http://player-b.example'
}
],
slides: [],
connectedClientsCount: 1,
connectedPlayersCount: 1
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
);
assert.match(html, /dashboard-kiosk-launcher-player-select/);
assert.match(html, /<option value="http:\/\/player-a\.example" data-player-identifier="player-alpha">player-alpha - http:\/\/player-a\.example<\/option>/);
assert.match(html, /<option value="http:\/\/player-b\.example" data-player-identifier="player-beta">player-beta - http:\/\/player-b\.example<\/option>/);
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 = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
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() {
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() {},
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 respects the live search input before the URL updates', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const searchInput = {
value: 'beta'
};
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
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 searchInput;
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '',
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() {},
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() {}
}
},
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() {},
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.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/);
});
+280 -5
View File
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const registerScreensRoutes = require('../src/web/routes/signage/screens/routes');
const { renderScreenEditPage } = require('../src/web/pages');
test('screen edit page includes shared launcher downloads and base player url', async () => {
const handlers = {};
@@ -34,10 +35,11 @@ test('screen edit page includes shared launcher downloads and base player url',
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchScreenPlayerUrls() {
return {
'demo-conference': 'http://player.example/screen/demo-conference'
};
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example', last_seen_at: new Date().toISOString() },
{ identifier: 'player-beta', public_base_url: 'http://beta.example/', last_seen_at: new Date().toISOString() }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
@@ -71,13 +73,218 @@ test('screen edit page includes shared launcher downloads and base player url',
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_url, 'http://player.example/screen/demo-conference');
assert.deepEqual(renderedArgs.screen.player_urls, [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
},
{
identifier: 'player-beta',
public_base_url: 'http://beta.example',
player_url: 'http://beta.example/screen/demo-conference'
}
]);
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
});
});
test('screen edit query route includes player urls for every registration', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let renderedArgs = null;
const pages = {
renderScreenEditPage(screen, data, message, currentUser) {
renderedArgs = { screen, data, message, currentUser };
return 'ok';
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example', last_seen_at: new Date().toISOString() }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages,
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const handler = handlers['/screens'];
assert.equal(Array.isArray(handler), true);
const res = {
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await handler[1]({ query: { edit: '7' }, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_urls, [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
}
]);
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
});
});
test('screen edit page hides stale player registrations', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let renderedArgs = null;
const now = Date.now();
const recentSeenAt = new Date(now - 10 * 1000).toISOString();
const staleSeenAt = new Date(now - 5 * 60 * 1000).toISOString();
const pages = {
renderScreenEditPage(screen, data, message, currentUser) {
renderedArgs = { screen, data, message, currentUser };
return 'ok';
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-stale', public_base_url: 'http://stale.example', last_seen_at: staleSeenAt },
{ identifier: 'player-recent', public_base_url: 'http://recent.example', last_seen_at: recentSeenAt }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages,
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
formatDashboardDate(value) {
return `formatted:${String(value)}`;
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const handler = handlers['/screens/:id/edit'];
assert.equal(Array.isArray(handler), true);
const res = {
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_urls.map(function (player) {
return {
identifier: player.identifier,
public_base_url: player.public_base_url,
player_url: player.player_url
};
}), [
{
identifier: 'player-recent',
public_base_url: 'http://recent.example',
player_url: 'http://recent.example/screen/demo-conference'
}
]);
});
test('screen edit page renders player urls as an adminlte table', async () => {
const html = renderScreenEditPage(
{
id: 7,
name: 'Demo Conference',
slug: 'demo-conference',
playlist_id: null,
player_urls: [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
}
]
},
{ playlists: [] },
'',
{ id: 1 }
);
assert.match(html, /Player URLs/);
assert.match(html, /<table/i);
assert.match(html, /card-body table-responsive p-0/);
assert.match(html, /table table-striped w-100 mb-0/);
assert.match(html, /player-alpha/);
});
test('screen launcher downloads are shared and attached', async () => {
const handlers = {};
const app = {
@@ -151,3 +358,71 @@ test('screen launcher downloads are shared and attached', async () => {
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
assert.match(res.body, /--kiosk/);
});
test('screen launcher downloads can target an explicit player url', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages: { renderScreenEditPage() { return 'ok'; } },
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const windowsHandler = handlers['/downloads/kiosk/pulse-signage-kiosk.bat'];
assert.equal(Array.isArray(windowsHandler), true);
const res = {
headers: {},
statusCode: 200,
set(name, value) {
this.headers[name.toLowerCase()] = value;
},
attachment(filename) {
this.headers['content-disposition'] = `attachment; filename="${filename}"`;
},
type(value) {
this.headers['content-type'] = value;
},
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await windowsHandler[1]({ query: { playerUrl: 'http://player-b.example/screen/demo-conference' } }, res, () => {});
assert.equal(res.statusCode, 200);
assert.match(res.body, /TARGET_URL=http:\/\/player-b\.example\/screen\/demo-conference/);
assert.doesNotMatch(res.body, /TARGET_URL=http:\/\/player\.example/);
});