diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc5fb0b..6b82016 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,32 @@
All notable changes to this project will be documented in this file.
+## 2.10.3 - 2026-08-28
+
+### Fixed
+
+- Fixed the weather edit preview so the daily forecast is shown initially, the 24-hour forecast is hidden until selected, and the redundant hourly heading is removed.
+- Fixed onboarding cleanup so incomplete pairings are retained for the 15-minute pairing-code lifetime while inactive completed bindings are pruned after 24 hours.
+- Fixed client-name handling so offline names can be reused, active collisions receive numeric suffixes, and reconnecting players resolve duplicate names consistently.
+- Fixed an internal server error when renaming clients by forwarding the available-name resolver to the client command routes.
+- Fixed remote client moves to resolve the paired browser client binding before changing its target screen.
+- Fixed remote player heartbeats to update the central onboarding bindings for active browser clients without treating the physical player registry ID as a client binding.
+- Fixed targeted commands after browser reconnects by falling back to the stable client ID when a transient connection ID is stale.
+- Fixed stale browser command connections remaining active indefinitely when the physical player heartbeat was still healthy.
+- Added periodic playlist polling so remote screens recover from missed refresh commands after bridge reconnects.
+- Added cached playlist snapshots so screens can continue displaying their last known playlist while the bridge or web service is temporarily unavailable.
+- Fixed media synchronization so generated player caches are excluded while remote image caches remain available to players.
+- Fixed weather screen notifications so changes in the fetched data or the current forecast hour trigger a refresh.
+- Fixed player runtime script loading and slide rendering so region modules, transitions, and cached playlists initialize consistently.
+- Fixed command delivery reporting to require acknowledgement from the receiving browser tab.
+- Fixed playlist refresh notifications to target the physical player hosting each screen instead of always targeting the local player.
+
## 2.10.2 - 2026-08-28
### Fixed
- Fixed the weather forecast preview to show only its first-fetch message until a successful forecast is available.
+- Added persisted onboarding client heartbeats so records inactive for 24 hours can be pruned without relying on player connectivity after the fact.
## 2.10.1 - 2026-08-28
diff --git a/build/package.player.json b/build/package.player.json
index 4887222..c7450ef 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
- "version": "2.10.2",
+ "version": "2.10.3",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
diff --git a/build/package.web.json b/build/package.web.json
index 896232f..90998d3 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
- "version": "2.10.2",
+ "version": "2.10.3",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example
index 44001ff..298436e 100644
--- a/docker-compose/.env.remote.example
+++ b/docker-compose/.env.remote.example
@@ -6,7 +6,7 @@ PULSE_SIGNAGE_SHARED_SECRET=""
# Remote player
PLAYER_IDENTIFIER="player-remote"
-PLAYER_PUBLIC_URL="http://localhost:8081"
+PLAYER_PUBLIC_URL="http://remote-player.example.com:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote bridge connectivity
diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml
index fc46ada..05b0cc5 100644
--- a/docker-compose/docker-compose.remote.yml
+++ b/docker-compose/docker-compose.remote.yml
@@ -11,8 +11,8 @@ services:
- "8081:8081"
environment:
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
- PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
- BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
+ PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:?PLAYER_PUBLIC_URL must be set to a routable remote-player URL}
+ BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:?BRIDGE_PUBLIC_URL must be set to a routable bridge URL}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
volumes:
diff --git a/package-lock.json b/package-lock.json
index 5e10b51..e2345d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
- "version": "2.10.2",
+ "version": "2.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "2.10.2",
+ "version": "2.10.3",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
diff --git a/package.json b/package.json
index 0afcf0d..13f0403 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.10.2",
+ "version": "2.10.3",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
diff --git a/src/common.js b/src/common.js
index 081967b..4075c17 100644
--- a/src/common.js
+++ b/src/common.js
@@ -28,10 +28,13 @@ const data = require('#src/data');
const player = require('#src/player/render');
const listQuery = require('#src/web/lib/list-query');
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
+const { findAvailableClientName } = require('#src/data/client-name-check');
module.exports = {
createPool: dbCommon.createPool,
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
+ touchOnboardingDeviceLastSeen: dbCommon.touchOnboardingDeviceLastSeen,
+ findAvailableClientName: findAvailableClientName,
ensureSchema: db.ensureSchema,
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
slugify: data.slugify,
diff --git a/src/data/announcement-icons.js b/src/data/announcement-icons.js
index 57c4026..e421366 100644
--- a/src/data/announcement-icons.js
+++ b/src/data/announcement-icons.js
@@ -1,3 +1,5 @@
+// Load and expose the announcement icon catalog used by the editor and player.
+
const fs = require('fs');
const path = require('path');
diff --git a/src/data/app-settings.js b/src/data/app-settings.js
index d0aa329..261e39c 100644
--- a/src/data/app-settings.js
+++ b/src/data/app-settings.js
@@ -1,3 +1,5 @@
+// Application-wide settings defaults and persistence helpers.
+
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
const SETTING_DEFINITIONS = [
@@ -35,13 +37,13 @@ const SETTING_DEFINITIONS = [
{ key: 'announcements.suggested_icons', type: 'string_array', defaultValue: DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice() },
{ key: 'player.default_slide_duration_seconds', type: 'integer', min: 1, defaultValue: 10 },
{ key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true },
- { key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true }
- ,{ key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
- ,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
- ,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
- ,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
- ,{ key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' }
- ,{ key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' }
+ { key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true },
+ { key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 },
+ { key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' },
+ { key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 },
+ { key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' },
+ { key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' },
+ { key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' }
];
const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) {
diff --git a/src/data/audit-log.js b/src/data/audit-log.js
index d892e7e..a91f643 100644
--- a/src/data/audit-log.js
+++ b/src/data/audit-log.js
@@ -1,3 +1,5 @@
+// Audit event definitions and data access helpers for administrative activity.
+
const AUDIT_EVENT_CATEGORIES = Object.freeze({
AUTHENTICATION: 'authentication',
SECURITY: 'security',
diff --git a/src/data/client-name-check.js b/src/data/client-name-check.js
index 531c122..f48e902 100644
--- a/src/data/client-name-check.js
+++ b/src/data/client-name-check.js
@@ -23,6 +23,9 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
const live = collectLiveConnections(liveConnections);
const lowerName = normalizedName.toLowerCase();
+ const liveDeviceIds = new Set(live.map(function (connection) {
+ return normalizeDeviceId(connection && (connection.deviceId || connection.clientId));
+ }).filter(Boolean));
try {
if (pool) {
@@ -32,12 +35,13 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
WHERE client_name IS NOT NULL
AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
- AND device_id <> ?
- LIMIT 1`,
+ AND device_id <> ?`,
[normalizedName, normalizedDeviceId]
);
- if (deviceRows.length) {
+ if ((deviceRows || []).some(function (row) {
+ return liveDeviceIds.has(normalizeDeviceId(row && row.device_id));
+ })) {
return false;
}
}
@@ -72,6 +76,22 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
}
}
+async function findAvailableClientName(pool, clientName, excludeDeviceId, liveConnections) {
+ const normalizedName = normalizeClientName(clientName);
+ if (!normalizedName) {
+ return '';
+ }
+
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
+ const candidate = suffix === 0 ? normalizedName : `${normalizedName} (${suffix})`;
+ if (await isClientNameAvailable(pool, candidate, excludeDeviceId, liveConnections)) {
+ return candidate;
+ }
+ }
+
+ return '';
+}
+
function buildClientNameLockName(clientName) {
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
}
@@ -116,5 +136,6 @@ module.exports = {
normalizeDeviceId: normalizeDeviceId,
collectLiveConnections: collectLiveConnections,
isClientNameAvailable: isClientNameAvailable,
+ findAvailableClientName: findAvailableClientName,
withClientNameReservation: withClientNameReservation
};
\ No newline at end of file
diff --git a/src/data/player-registry.js b/src/data/player-registry.js
index 94477de..e84b3ad 100644
--- a/src/data/player-registry.js
+++ b/src/data/player-registry.js
@@ -1,3 +1,5 @@
+// Persistent player registration and heartbeat helpers shared by the web app and bridge.
+
function normalizeDeviceId(value) {
return String(value || '')
.trim()
diff --git a/src/data/qr-code.js b/src/data/qr-code.js
index f1889b3..6090a36 100644
--- a/src/data/qr-code.js
+++ b/src/data/qr-code.js
@@ -1,3 +1,5 @@
+// QR code generation helpers for player onboarding and administrative links.
+
const path = require('path');
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
const QR_PNG_WIDTH = 2048;
diff --git a/src/db/common.js b/src/db/common.js
index 7ff71fe..0e5cd18 100644
--- a/src/db/common.js
+++ b/src/db/common.js
@@ -1,3 +1,5 @@
+// Database pool setup and lifecycle maintenance for persisted onboarding devices.
+
const mysql = require('mysql2/promise');
function createPool() {
@@ -15,14 +17,34 @@ function createPool() {
}
async function pruneStaleOnboardingDevices(pool) {
+ // Uncompleted pairings expire quickly; completed bindings use their persisted heartbeat instead.
await pool.query(
`DELETE FROM d_onboarding_devices
- WHERE screen_id IS NULL
- AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
+ WHERE (screen_id IS NULL
+ AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 15 MINUTE))
+ OR (last_seen_at IS NOT NULL
+ AND last_seen_at < (CURRENT_TIMESTAMP - INTERVAL 24 HOUR))`
+ );
+}
+
+async function touchOnboardingDeviceLastSeen(pool, deviceId) {
+ const deviceIds = (Array.isArray(deviceId) ? deviceId : [deviceId])
+ .map(function (value) { return String(value || '').trim(); })
+ .filter(Boolean);
+ if (!deviceIds.length) {
+ return;
+ }
+
+ await pool.query(
+ `UPDATE d_onboarding_devices
+ SET last_seen_at = CURRENT_TIMESTAMP
+ WHERE device_id IN (${deviceIds.map(function () { return '?'; }).join(', ')})`,
+ deviceIds
);
}
module.exports = {
createPool,
- pruneStaleOnboardingDevices
+ pruneStaleOnboardingDevices,
+ touchOnboardingDeviceLastSeen
};
\ No newline at end of file
diff --git a/src/db/index.js b/src/db/index.js
index 9da60a6..7ecb867 100644
--- a/src/db/index.js
+++ b/src/db/index.js
@@ -1,3 +1,5 @@
+// Schema initialization, migration execution, and database bootstrap helpers.
+
const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
@@ -331,6 +333,7 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
+ last_seen_at TIMESTAMP NULL,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
diff --git a/src/db/migrations.js b/src/db/migrations.js
index 8a5f0e9..4d1b882 100644
--- a/src/db/migrations.js
+++ b/src/db/migrations.js
@@ -1,3 +1,5 @@
+// Ordered database migrations kept independent from the application version.
+
const { version: appVersion } = require('#root/package.json');
const TIMETABLE_TIME_ZONE = 'Europe/London';
const APP_STATE_TABLE = 'o_app_state';
@@ -552,6 +554,13 @@ const VERSIONED_MIGRATIONS = [
run: async function (pool) {
await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color');
}
+ },
+ {
+ version: '2.10.2',
+ label: 'v2.10.2 onboarding client last-seen schema',
+ run: async function (pool) {
+ await ensureColumn(pool, 'd_onboarding_devices', 'last_seen_at', 'TIMESTAMP NULL', 'screen_id');
+ }
}
];
diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js
index 942394d..aea954c 100644
--- a/src/player-bridge/index.js
+++ b/src/player-bridge/index.js
@@ -1,3 +1,5 @@
+// Thin-client bridge for player registration, snapshots, commands, and heartbeats.
+
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
@@ -8,7 +10,6 @@ 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');
@@ -458,7 +459,21 @@ async function start() {
function storeScreenSnapshot(slug, connections, deviceIds) {
const key = String(slug || '').trim();
- const normalizedConnections = Array.isArray(connections) ? connections : [];
+ const normalizedConnections = [];
+ const connectionIndexes = new Map();
+ (Array.isArray(connections) ? connections : []).forEach(function (connection) {
+ const identity = connection && typeof connection === 'object'
+ ? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|')
+ : '';
+ if (!identity || !connectionIndexes.has(identity)) {
+ if (identity) {
+ connectionIndexes.set(identity, normalizedConnections.length);
+ }
+ normalizedConnections.push(connection);
+ return;
+ }
+ normalizedConnections[connectionIndexes.get(identity)] = connection;
+ });
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean) : [];
@@ -654,6 +669,16 @@ async function start() {
}, response && typeof response === 'object' ? response : {});
}));
+ logBridge('Screen command result', {
+ screenSlug: slug,
+ command: command,
+ connectionId: connectionId || null,
+ targets: targetPlayers.map(function (target) { return target.deviceId; }),
+ results: results.map(function (result) {
+ return { playerIdentifier: result.playerIdentifier, ok: result.ok, status: result.status, error: result.error || null };
+ })
+ });
+
res.json({
ok: true,
screenSlug: slug,
@@ -882,48 +907,6 @@ async function start() {
}
});
- 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);
@@ -989,7 +972,14 @@ async function start() {
return;
}
- setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []);
+ const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, '');
+ const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) {
+ if (!connection || typeof connection !== 'object' || !snapshotPlayerPublicBaseUrl) {
+ return connection;
+ }
+ return Object.assign({}, connection, { playerPublicBaseUrl: snapshotPlayerPublicBaseUrl });
+ }) : [];
+ setScreenSnapshotSource(slug, deviceId, connections);
return;
}
@@ -1021,6 +1011,12 @@ async function start() {
publicBaseUrl: payload.publicBaseUrl,
internalBaseUrl: payload.internalBaseUrl
});
+ if (typeof common.touchOnboardingDeviceLastSeen === 'function') {
+ const onboardingClientIds = (Array.isArray(payload.connections) ? payload.connections : []).map(function (connection) {
+ return connection && connection.clientId;
+ });
+ await common.touchOnboardingDeviceLastSeen(pool, onboardingClientIds);
+ }
socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player }));
return;
diff --git a/src/player.js b/src/player.js
index e8197ef..cda699a 100644
--- a/src/player.js
+++ b/src/player.js
@@ -1,3 +1,5 @@
+// Player application bootstrap, media routes, websocket runtime, and onboarding wiring.
+
const express = require('express');
const fs = require('fs');
const http = require('http');
@@ -10,8 +12,10 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes');
+const { getPlayerRuntimeScripts } = require('./player/render-helpers');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { createRequestAuthHeaders } = require('#src/request-auth');
+const { withClientNameReservation } = require('#src/data/client-name-check');
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
@@ -52,11 +56,28 @@ async function start() {
thinClientSocket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
+ playerPublicBaseUrl: getPlayerPublicBaseUrl(),
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
}));
} catch (_error) {
}
+ },
+ persistClientName: async function (deviceId, clientName) {
+ if (!pool || !deviceId || !clientName) {
+ return;
+ }
+ await withClientNameReservation(pool, clientName, async function () {
+ await pool.query(
+ `UPDATE d_onboarding_devices
+ SET client_name = ?, modified_at = CURRENT_TIMESTAMP
+ WHERE device_id = ?`,
+ [clientName, deviceId]
+ );
+ });
+ },
+ touchClientLastSeen: async function (deviceId) {
+ await common.touchOnboardingDeviceLastSeen(pool, deviceId);
}
});
const playerPlaylistService = isRemotePlayer
@@ -320,6 +341,14 @@ async function start() {
}
}
});
+ app.get('/assets/player-script/:name.js', function (req, res) {
+ const script = getPlayerRuntimeScripts().find(function (entry) { return entry[0] === req.params.name; });
+ if (!script) {
+ return res.sendStatus(404);
+ }
+ res.set('Cache-Control', 'no-cache');
+ return res.type('application/javascript').send(script[1]);
+ });
registerPlayerRoutes(app, {
pool: pool,
common: common,
@@ -328,6 +357,7 @@ async function start() {
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService,
+ snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists'),
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID,
@@ -391,7 +421,8 @@ async function start() {
internalBaseUrl: PLAYER_INTERNAL_URL,
pairingCode: activePairingCode,
pairingCodes: activePairingCodes,
- pairingSessions: activePairingSessions
+ pairingSessions: activePairingSessions,
+ connections: playerRuntime.snapshotAllConnections()
}));
}
@@ -439,6 +470,7 @@ async function start() {
socket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
+ playerPublicBaseUrl: getPlayerPublicBaseUrl(),
slug: String(slug || '').trim(),
connections: playerRuntime.snapshotConnections(slug)
}));
@@ -565,10 +597,6 @@ async function start() {
console.error(error);
});
- if (playerRuntime.snapshotAllConnections().length > 0) {
- await common.pruneStaleOnboardingDevices(pool);
- }
-
await onboardingStore.flushBindings(function (entry) {
return commitDeviceBinding(
pool,
diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js
index f43ed15..22a57e3 100644
--- a/src/player/onboarding/index.js
+++ b/src/player/onboarding/index.js
@@ -2,7 +2,7 @@
const express = require('express');
const crypto = require('crypto');
-const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
+const { findAvailableClientName, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
@@ -144,8 +144,8 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
const screen = screenRows[0];
- const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
- if (!available) {
+ const selectedClientName = await findAvailableClientName(pool, normalizedClientName, normalizedDeviceId, liveConnections);
+ if (!selectedClientName) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
throw error;
@@ -153,9 +153,9 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
await pool.query(
`UPDATE d_onboarding_devices
- SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
+ SET client_name = ?, screen_id = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
- [normalizedClientName, screen.id, normalizedDeviceId]
+ [selectedClientName, screen.id, normalizedDeviceId]
);
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
@@ -163,7 +163,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
WHERE NOT EXISTS (
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
)`,
- [normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
+ [normalizedDeviceId, selectedClientName, screen.id, normalizedDeviceId]
);
return getOnboardingStatus(pool, normalizedDeviceId);
@@ -581,6 +581,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}
if (response.ok) {
pairingSessions.delete(deviceId);
+ res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
@@ -600,6 +601,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
pairingSessions.delete(deviceId);
+ res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html
index 4b6a4fe..ec5ad9e 100644
--- a/src/player/onboarding/player-onboarding-landing.script.html
+++ b/src/player/onboarding/player-onboarding-landing.script.html
@@ -60,7 +60,7 @@
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
- window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
+ window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
return true;
}
return false;
diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html
index abb43be..f34fe56 100644
--- a/src/player/player-page.script.html
+++ b/src/player/player-page.script.html
@@ -1,41 +1,11 @@
+
+
\ No newline at end of file
diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css
index 43cfbaf..1f4e5a2 100644
--- a/src/player/public/css/player.css
+++ b/src/player/public/css/player.css
@@ -22,21 +22,6 @@ body.onboarding-page #app {
display: none;
}
-body.thumbnail-preview *,
-body.thumbnail-preview *::before,
-body.thumbnail-preview *::after {
- animation: none !important;
- animation-delay: 0s !important;
- animation-duration: 0s !important;
- animation-iteration-count: 1 !important;
- transition: none !important;
-}
-
-body.thumbnail-preview .player-announcement-layer,
-body.thumbnail-preview .player-offline-banner {
- display: none !important;
-}
-
#app {
width: 100%;
height: 100%;
diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js
index 54be74b..e76f78e 100644
--- a/src/player/public/js/player-page-commands.js
+++ b/src/player/public/js/player-page-commands.js
@@ -7,6 +7,7 @@ function getCurrentViewport() {
}
var slideOutroTimers = [];
+var commandHeartbeatTimer = null;
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
@@ -166,19 +167,11 @@ function getPlayerRegionModules() {
return window.pulsePlayerRegionTypes.list();
}
-function isThumbnailPreview() {
- return Boolean(window.__pulseThumbnailPreview);
-}
-
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
- if (isThumbnailPreview() && lifecycleName === 'initRegion') {
- return;
- }
-
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
@@ -276,9 +269,7 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(root);
}
- if (!isThumbnailPreview()) {
- initializeRegionInstances(root);
- }
+ initializeRegionInstances(root);
initializeRenderedVideoPlayback(root, delayMs);
@@ -330,13 +321,16 @@ function renderSlideMarkup(markup, shouldFade) {
}
var nextShell = document.createElement('div');
- nextShell.className = 'slide-shell';
+ nextShell.className = 'slide-shell slide-shell-entering';
+ nextShell.style.zIndex = '0';
nextShell.style.opacity = '0';
nextShell.innerHTML = markup;
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
app.innerHTML = '';
nextShell.style.opacity = '1';
+ nextShell.classList.remove('slide-shell-entering');
+ nextShell.classList.add('is-visible');
app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
return nextShell;
@@ -345,6 +339,9 @@ function renderSlideMarkup(markup, shouldFade) {
if (!previousShell.classList.contains('slide-shell')) {
previousShell.classList.add('slide-shell');
}
+ previousShell.classList.remove('is-visible');
+ previousShell.classList.add('is-exiting');
+ previousShell.style.zIndex = '1';
previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
@@ -352,6 +349,8 @@ function renderSlideMarkup(markup, shouldFade) {
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
+ nextShell.classList.remove('slide-shell-entering');
+ nextShell.classList.add('is-visible');
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
});
@@ -360,7 +359,10 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.parentNode.removeChild(previousShell);
}
if (nextShell) {
+ nextShell.style.zIndex = '1';
nextShell.style.opacity = '1';
+ nextShell.classList.remove('slide-shell-entering');
+ nextShell.classList.add('is-visible');
}
slideTransitionTimer = null;
}, slideFadeDurationMs);
@@ -537,14 +539,28 @@ function handleCommandMessage(rawMessage) {
handleClientIdConflict();
return;
}
+ if (payload && payload.type === 'client-name-updated') {
+ if (payload.clientName) {
+ applyOnboardingClientName(payload.clientName, commandSocket);
+ }
+ return;
+ }
if (!payload || payload.type !== 'command') {
return;
}
+ if (payload.requestId && commandSocket && commandSocket.readyState === WebSocket.OPEN) {
+ commandSocket.send(JSON.stringify({
+ type: 'command-ack',
+ requestId: payload.requestId,
+ ok: true
+ }));
+ }
+
switch (payload.command) {
case 'refresh':
- refresh();
+ refresh(true);
return;
case 'setclientname':
if (payload.clientName) {
@@ -602,7 +618,6 @@ function handleCommandMessage(rawMessage) {
function handleClientIdConflict() {
var replacementClientId = regenerateCommandClientId();
var onboardingUrl = new URL('/', window.location.origin);
- onboardingUrl.searchParams.set('clientId', replacementClientId);
window.location.replace(onboardingUrl.toString());
}
@@ -629,6 +644,12 @@ function connectCommandSocket() {
commandSocket = socket;
socket.onopen = function () {
+ if (commandHeartbeatTimer) {
+ window.clearInterval(commandHeartbeatTimer);
+ }
+ commandHeartbeatTimer = window.setInterval(function () {
+ sendCommandState(lastRenderedSlide);
+ }, 60 * 1000);
if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandState(socket);
@@ -643,6 +664,13 @@ function connectCommandSocket() {
};
socket.onclose = function (event) {
+ if (typeof logDebug === 'function') {
+ logDebug('Command websocket closed.', 'code=' + String(event && event.code || '') + ' reason=' + String(event && event.reason || ''), 'warn');
+ }
+ if (commandHeartbeatTimer) {
+ window.clearInterval(commandHeartbeatTimer);
+ commandHeartbeatTimer = null;
+ }
commandSocket = null;
if (event && event.code === 4009) {
handleClientIdConflict();
@@ -651,7 +679,10 @@ function connectCommandSocket() {
scheduleCommandReconnect();
};
- socket.onerror = function () {
+ socket.onerror = function (error) {
+ if (typeof logDebug === 'function') {
+ logDebug('Command websocket error.', error && error.message ? String(error.message) : 'Websocket transport error.', 'error');
+ }
try {
socket.close();
} catch (_error) {
diff --git a/src/player/public/js/player-page-offline.js b/src/player/public/js/player-page-offline.js
index 439f114..d7c97eb 100644
--- a/src/player/public/js/player-page-offline.js
+++ b/src/player/public/js/player-page-offline.js
@@ -1,8 +1,5 @@
// Show or hide the offline status banner.
function setOfflineBannerVisible(visible, message) {
- if (window.__pulseThumbnailPreview) {
- return;
- }
var normalizedVisible = Boolean(visible);
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
if (normalizedVisible) {
@@ -41,9 +38,6 @@ function setOfflineBannerVisible(visible, message) {
// Update the offline banner based on connectivity or playlist availability.
function syncOfflineBanner() {
- if (window.__pulseThumbnailPreview) {
- return;
- }
if (!window.navigator.onLine) {
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
return;
@@ -63,9 +57,6 @@ function clearRefreshRetry() {
// Retry playlist refresh with a short backoff while the player is offline.
function scheduleRefreshRetry() {
- if (window.__pulseThumbnailPreview) {
- return;
- }
if (refreshRetryTimer) {
return;
}
diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js
index d49bda1..54bcaa3 100644
--- a/src/player/public/js/player-page-playback.js
+++ b/src/player/public/js/player-page-playback.js
@@ -1,5 +1,5 @@
// Render the slide at the requested index within the active set.
-async function renderSlideAtIndex(sourceSlides, targetIndex) {
+async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
@@ -53,7 +53,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
index = currentIndex;
var markup = buildSlideMarkup(slide);
- renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
+ renderSlideMarkup(markup, !(options && options.skipFade) && currentPlaylistFadeBetweenSlides);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
@@ -91,7 +91,7 @@ function applyPendingPlaylistUpdate() {
}
// Render the current active slide or the empty state.
-function showCurrent() {
+function showCurrent(options) {
clearSlideTimer();
const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index);
@@ -106,7 +106,7 @@ function showCurrent() {
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return;
}
- void renderSlideAtIndex(activeSlides, index);
+ void renderSlideAtIndex(activeSlides, index, options);
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
}
@@ -148,7 +148,7 @@ function handleRtmpPlaybackFailure(message, options) {
}
// Fetch the latest playlist and queue any updates.
-function refresh() {
+function refresh(applyImmediately) {
var request = new XMLHttpRequest();
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
request.open('GET', url, true);
@@ -167,6 +167,7 @@ function refresh() {
return;
}
if (request.status === 304) {
+ logDebug('Playlist refresh completed with no changes.');
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
@@ -192,6 +193,7 @@ function refresh() {
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data);
+ logDebug('Playlist refresh completed.', 'Revision: ' + nextSignature);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
@@ -254,6 +256,11 @@ function refresh() {
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
+ if (applyImmediately) {
+ applyPendingPlaylistUpdate();
+ showCurrent();
+ return;
+ }
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
applyPendingPlaylistUpdate();
showCurrent();
@@ -268,6 +275,11 @@ function refresh() {
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
+ if (applyImmediately) {
+ applyPendingPlaylistUpdate();
+ showCurrent();
+ return;
+ }
logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) {
logDebug(
diff --git a/src/player/public/js/player-page-playlist.js b/src/player/public/js/player-page-playlist.js
index 574e41e..73706d2 100644
--- a/src/player/public/js/player-page-playlist.js
+++ b/src/player/public/js/player-page-playlist.js
@@ -129,10 +129,6 @@ function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
}
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
- if (window.__pulseThumbnailPreview) {
- return;
- }
-
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
return;
@@ -236,6 +232,7 @@ function loadPlaylistSnapshot() {
slides: parsed.slides.map(normalizeSlide),
signature: String(parsed.signature || ''),
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
+ skipUnavailableRtmp: Boolean(parsed.skipUnavailableRtmp),
etag: String(parsed.etag || '')
};
} catch (_error) {
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index 32a4731..3813e6b 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -227,7 +227,7 @@ function getRegionAnimationPhaseTimingMs(root, phase) {
}
function getRegionAnimationPhaseTimings(root, phase) {
- if (!root || isThumbnailPreview()) {
+ if (!root) {
return [];
}
@@ -266,7 +266,7 @@ function getRegionAnimationPhaseTimings(root, phase) {
}
function playRegionAnimation(element, phase) {
- if (!element || isThumbnailPreview()) {
+ if (!element) {
return;
}
@@ -316,7 +316,7 @@ function playRegionAnimation(element, phase) {
}
function playRegionAnimations(root, phase) {
- if (!root || isThumbnailPreview()) {
+ if (!root) {
return;
}
@@ -797,7 +797,7 @@ function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGrad
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
- if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
+ if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
@@ -975,7 +975,7 @@ function notifyVideoRegionSourceReady() {
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
- showCurrent();
+ showCurrent({ skipFade: true });
}
}
diff --git a/src/player/public/js/player-region-registry.js b/src/player/public/js/player-region-registry.js
new file mode 100644
index 0000000..2f6c914
--- /dev/null
+++ b/src/player/public/js/player-region-registry.js
@@ -0,0 +1,34 @@
+// Registry used by the player runtime to discover independently loaded region modules.
+
+(function () {
+ var root = window;
+ var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
+
+ function normalizeType(type) {
+ return String(type || '').trim().toLowerCase();
+ }
+
+ function register(type, definition) {
+ registry[normalizeType(type)] = definition || {};
+ return registry[normalizeType(type)];
+ }
+
+ function get(type) {
+ return registry[normalizeType(type)] || null;
+ }
+
+ function list() {
+ return Object.keys(registry).map(function (type) {
+ return {
+ type: type,
+ definition: registry[type] || {}
+ };
+ });
+ }
+
+ root.pulsePlayerRegionTypes = {
+ register: register,
+ get: get,
+ list: list
+ };
+}());
\ No newline at end of file
diff --git a/src/player/regions/rtmp.js b/src/player/regions/rtmp.js
index 1c70235..f9a2b57 100644
--- a/src/player/regions/rtmp.js
+++ b/src/player/regions/rtmp.js
@@ -1,3 +1,5 @@
+// RTMP region markup and playback lifecycle hooks.
+
var registry = window.pulsePlayerRegionTypes;
function renderRtmpRegion(region, regionContent) {
diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js
index 4b3632f..a9fdc6f 100644
--- a/src/player/render-helpers.js
+++ b/src/player/render-helpers.js
@@ -1,3 +1,5 @@
+// Assemble player HTML and inline runtime scripts from the server-side templates.
+
const fs = require('fs');
const path = require('path');
const announcementIcons = require('#src/data/announcement-icons');
@@ -523,6 +525,32 @@ function getPlayerOnboardingFormScript() {
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
}
+function getPlayerRuntimeScripts() {
+ function getScriptBody(value) {
+ return String(value || '')
+ .replace(/^\s*';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
- const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
- const script = getPlayerPageScript()({
+ const bootstrapScript = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
- REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
+ REGION_SCRIPTS: ''
});
+ const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
+ return '';
+ }).join('');
return renderPage(template, {
title: 'Screen ' + slug,
- bodyClass: bodyClass,
+ bodyClass: '',
body: '
',
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
- script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + '' + '' + '' + onboardingScript + script + ''
+ script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + ''
});
}
diff --git a/src/player/routes.js b/src/player/routes.js
index a866fcb..f710e33 100644
--- a/src/player/routes.js
+++ b/src/player/routes.js
@@ -5,7 +5,6 @@ const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
-const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -24,6 +23,23 @@ function isBridgeFetchError(error) {
));
}
+function getRequestClientId(req) {
+ const headerClientId = String(req && req.headers && req.headers['x-pulse-client-id'] || '').trim();
+ if (headerClientId) {
+ return headerClientId;
+ }
+ const queryClientId = String(req && req.query && req.query.clientId || '').trim();
+ if (queryClientId) {
+ return queryClientId;
+ }
+ const cookieHeader = String(req && req.headers && req.headers.cookie || '');
+ const cookie = cookieHeader.split(';').map(function (part) {
+ const separator = part.indexOf('=');
+ return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
+ }).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-player-client-id'; });
+ return cookie ? decodeURIComponent(cookie[1]) : '';
+}
+
function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
@@ -35,6 +51,7 @@ function registerPlayerRoutes(app, options) {
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
+ const snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : null;
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
@@ -71,6 +88,11 @@ function registerPlayerRoutes(app, options) {
}
if (requestHeaders['x-pulse-client-id']) {
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
+ } else {
+ const clientId = getRequestClientId(req);
+ if (clientId) {
+ headers['x-pulse-client-id'] = clientId;
+ }
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
@@ -130,7 +152,7 @@ function registerPlayerRoutes(app, options) {
}
async function isClientAuthorizedForScreen(req, requestedSlug) {
- const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
+ const clientId = getRequestClientId(req);
if (!clientId) {
return false;
}
@@ -219,6 +241,32 @@ function registerPlayerRoutes(app, options) {
return resolvedFilePath;
}
+ function getSnapshotFilePath(slug) {
+ const normalizedSlug = String(slug || '').trim();
+ return snapshotDir && normalizedSlug ? path.join(snapshotDir, `${normalizedSlug}.json`) : null;
+ }
+
+ async function readPlaylistSnapshot(slug) {
+ const filePath = getSnapshotFilePath(slug);
+ if (!filePath) {
+ return null;
+ }
+ try {
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
+ } catch (_error) {
+ return null;
+ }
+ }
+
+ async function writePlaylistSnapshot(slug, payload) {
+ const filePath = getSnapshotFilePath(slug);
+ if (!filePath || !payload) {
+ return;
+ }
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
+ }
+
app.use('/assets', express.static(assetDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/media', express.static(mediaDir));
@@ -371,7 +419,7 @@ function registerPlayerRoutes(app, options) {
});
app.get('/screen/:slug', async function (req, res, next) {
- if (onPlayerPublicBaseUrl) {
+ if (onPlayerPublicBaseUrl && !bridgeBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
} catch (_error) {
@@ -387,21 +435,25 @@ function registerPlayerRoutes(app, options) {
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) {
if (!response || response.status >= 400) {
+ const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
- return res.send(common.renderPlayerPage(req.params.slug, null));
+ return res.send(common.renderPlayerPage(req.params.slug, snapshot));
}
const data = await readJsonResponse(response);
if (!data) {
+ const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
- return res.send(common.renderPlayerPage(req.params.slug, null));
+ return res.send(common.renderPlayerPage(req.params.slug, snapshot));
}
+ await writePlaylistSnapshot(req.params.slug, data);
res.send(common.renderPlayerPage(req.params.slug, null));
- }).catch(function (error) {
+ }).catch(async function (error) {
if (!isBridgeFetchError(error)) {
console.error(error);
}
+ const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
- res.send(common.renderPlayerPage(req.params.slug, null));
+ res.send(common.renderPlayerPage(req.params.slug, snapshot));
});
return;
}
@@ -415,61 +467,6 @@ function registerPlayerRoutes(app, options) {
});
});
- app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
- try {
- if (bridgeBaseUrl) {
- const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
- method: 'GET'
- });
- if (!response) {
- return res.status(502).send('Thin client unavailable');
- }
- res.status(response.status);
- res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
- res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
- return res.send(await response.text());
- }
-
- 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.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
@@ -480,7 +477,13 @@ function registerPlayerRoutes(app, options) {
method: 'GET'
});
if (!response) {
- return res.status(502).json({ error: 'Thin client unavailable.' });
+ const snapshot = await readPlaylistSnapshot(req.params.slug);
+ if (!snapshot) {
+ return res.status(502).json({ error: 'Thin client unavailable.' });
+ }
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.set('ETag', '"' + String(snapshot.revision || '') + '"');
+ return res.json(snapshot);
}
res.status(response.status);
const etag = response.headers.get('etag');
@@ -495,7 +498,12 @@ function registerPlayerRoutes(app, options) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
- return res.send(await response.text());
+ const responseText = await response.text();
+ try {
+ await writePlaylistSnapshot(req.params.slug, JSON.parse(responseText));
+ } catch (_error) {
+ }
+ return res.send(responseText);
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
diff --git a/src/player/runtime.js b/src/player/runtime.js
index 6e966c0..509cd17 100644
--- a/src/player/runtime.js
+++ b/src/player/runtime.js
@@ -2,7 +2,7 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
-const { isClientNameAvailable } = require('#src/data/client-name-check');
+const { findAvailableClientName, isClientNameAvailable } = require('#src/data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
@@ -22,6 +22,8 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
+ const persistClientName = typeof options.persistClientName === 'function' ? options.persistClientName : null;
+ const touchClientLastSeen = typeof options.touchClientLastSeen === 'function' ? options.touchClientLastSeen : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
? options.normalizeDeviceId
: function (value) {
@@ -30,7 +32,11 @@ function createPlayerRuntime(options) {
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const announcementListenersBySlug = new Map();
+ const pendingCommandAcks = new Map();
const wss = new WebSocketServer({ noServer: true });
+ const staleConnectionMs = Number(options && options.staleConnectionMs) > 0
+ ? Number(options.staleConnectionMs)
+ : 3 * 60 * 1000;
function hasActiveClientId(clientId, currentConnection) {
const normalizedClientId = String(clientId || '').trim();
@@ -180,6 +186,57 @@ function createPlayerRuntime(options) {
}
}
+ function removeStaleConnections() {
+ const cutoff = Date.now() - staleConnectionMs;
+ for (const [slug, bucket] of connectionsBySlug.entries()) {
+ for (const [connectionId, connection] of bucket.entries()) {
+ if (connection.lastSeenAt && connection.lastSeenAt.getTime() > cutoff) {
+ continue;
+ }
+ removeConnection(slug, connectionId);
+ try {
+ connection.socket.close(1000, 'Connection heartbeat expired.');
+ } catch (_error) {
+ }
+ broadcastConnectionSnapshot(slug);
+ }
+ }
+ }
+
+ const staleConnectionSweep = setInterval(removeStaleConnections, Math.min(staleConnectionMs, 60 * 1000));
+ if (typeof staleConnectionSweep.unref === 'function') {
+ staleConnectionSweep.unref();
+ }
+
+ function checkWebsocketHealth() {
+ for (const [slug, bucket] of connectionsBySlug.entries()) {
+ for (const [connectionId, connection] of bucket.entries()) {
+ if (!connection.isAlive) {
+ removeConnection(slug, connectionId);
+ try {
+ connection.socket.terminate();
+ } catch (_error) {
+ }
+ broadcastConnectionSnapshot(slug);
+ continue;
+ }
+
+ connection.isAlive = false;
+ try {
+ connection.socket.ping();
+ } catch (_error) {
+ removeConnection(slug, connectionId);
+ broadcastConnectionSnapshot(slug);
+ }
+ }
+ }
+ }
+
+ const websocketHealthCheck = setInterval(checkWebsocketHealth, 30 * 1000);
+ if (typeof websocketHealthCheck.unref === 'function') {
+ websocketHealthCheck.unref();
+ }
+
function getDashboardListenerBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
@@ -370,7 +427,10 @@ function createPlayerRuntime(options) {
return 0;
}
- const target = bucket.get(String(connectionId || '').trim());
+ const normalizedConnectionId = String(connectionId || '').trim();
+ const target = bucket.get(normalizedConnectionId) || Array.from(bucket.values()).find(function (connection) {
+ return String(connection && connection.clientId || '').trim() === normalizedConnectionId;
+ });
if (!target || target.socket.readyState !== WebSocket.OPEN) {
return 0;
}
@@ -382,8 +442,28 @@ function createPlayerRuntime(options) {
payload.targetConnectionId = target.id;
payload.sentAt = new Date().toISOString();
+ const requestId = String(payload.requestId || '').trim();
+ if (!requestId) {
+ target.socket.send(JSON.stringify(payload));
+ return 1;
+ }
+
+ const acknowledgement = new Promise(function (resolve) {
+ const timeout = setTimeout(function () {
+ pendingCommandAcks.delete(requestId);
+ resolve(0);
+ }, 5000);
+ pendingCommandAcks.set(requestId, {
+ connection: target,
+ resolve: function (acknowledged) {
+ clearTimeout(timeout);
+ pendingCommandAcks.delete(requestId);
+ resolve(acknowledged ? 1 : 0);
+ }
+ });
+ });
target.socket.send(JSON.stringify(payload));
- return 1;
+ return await acknowledgement;
}
async function broadcastCommand(slug, commandOrPayload) {
@@ -527,6 +607,7 @@ function createPlayerRuntime(options) {
connectedAt: new Date(),
lastSeenAt: new Date()
};
+ connection.isAlive = true;
const bucket = getConnectionBucket(slug);
if (!bucket) {
@@ -536,7 +617,11 @@ function createPlayerRuntime(options) {
bucket.set(connectionId, connection);
- socket.on('message', function (rawMessage) {
+ socket.on('pong', function () {
+ connection.isAlive = true;
+ });
+
+ socket.on('message', async function (rawMessage) {
connection.lastSeenAt = new Date();
let payload = null;
try {
@@ -546,6 +631,13 @@ function createPlayerRuntime(options) {
}
if (!payload || payload.type !== 'state') {
+ if (payload && payload.type === 'command-ack') {
+ const requestId = String(payload.requestId || '').trim();
+ const pendingAck = pendingCommandAcks.get(requestId);
+ if (pendingAck && pendingAck.connection === connection) {
+ pendingAck.resolve(payload.ok !== false);
+ }
+ }
return;
}
@@ -561,6 +653,26 @@ function createPlayerRuntime(options) {
if (!connection.clientName && connection.clientId) {
connection.clientName = connection.clientId;
}
+ if (connection.clientName) {
+ const connectionIdentity = connection.deviceId || connection.clientId;
+ const selectedClientName = await findAvailableClientName(pool, connection.clientName, connectionIdentity, snapshotAllConnections());
+ if (selectedClientName && selectedClientName !== connection.clientName) {
+ connection.clientName = selectedClientName;
+ if (persistClientName) {
+ await persistClientName(connection.deviceId, selectedClientName);
+ }
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 'client-name-updated', clientName: selectedClientName }));
+ }
+ }
+ }
+ if (touchClientLastSeen) {
+ try {
+ await touchClientLastSeen(connection.clientId);
+ } catch (_error) {
+ // A heartbeat failure must not interrupt playback or websocket state.
+ }
+ }
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page;
@@ -581,12 +693,24 @@ function createPlayerRuntime(options) {
broadcastConnectionSnapshot(slug);
});
- socket.on('close', function () {
+ socket.on('close', function (code, reason) {
+ pendingCommandAcks.forEach(function (pendingAck, requestId) {
+ if (pendingAck.connection === connection) {
+ pendingAck.resolve(false);
+ pendingCommandAcks.delete(requestId);
+ }
+ });
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
socket.on('error', function () {
+ pendingCommandAcks.forEach(function (pendingAck, requestId) {
+ if (pendingAck.connection === connection) {
+ pendingAck.resolve(false);
+ pendingCommandAcks.delete(requestId);
+ }
+ });
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js
deleted file mode 100644
index b50d790..0000000
--- a/src/player/thumbnail-preview.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
-
-function buildThumbnailPreviewData(slide) {
- return {
- thumbnailPreview: true,
- screen: {
- id: slide.id,
- name: slide.title,
- slug: 'slide-thumbnail-preview-' + slide.id,
- playlist_id: null
- },
- playlist: {
- fade_between_slides: false
- },
- slides: [slide],
- rssFeeds: [],
- apiSources: [],
- timetableGroups: [],
- revision: String(slide.modified_at || slide.id || Date.now())
- };
-}
-
-function createThumbnailPreviewBootstrapScript(initialData) {
- return initialData && initialData.thumbnailPreview ? '' : '';
-}
-
-module.exports = {
- buildThumbnailPreviewData: buildThumbnailPreviewData,
- createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
-};
\ No newline at end of file
diff --git a/src/web.js b/src/web.js
index c22396f..64e1458 100644
--- a/src/web.js
+++ b/src/web.js
@@ -67,7 +67,12 @@ async function start() {
playerInternalBaseUrl: webConfig.playerInternalUrl,
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
});
- const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
+ const notifyPlayerScreens = createNotifyPlayerScreens(
+ playerActionService.forwardPlayerCommand,
+ playerActionService.getScreenConnections,
+ playerActionService.forwardPlayerCommandToBaseUrl,
+ playerActionService.resolvePlayerBaseUrlForPublicUrl
+ );
// Centralized dashboard/player bootstrap.
const webBootstrap = createWebBootstrap({
@@ -165,6 +170,7 @@ async function start() {
playerActionService: playerActionService,
playerInternalBaseUrl: webConfig.playerInternalUrl,
isClientNameAvailable: isClientNameAvailable,
+ findAvailableClientName: common.findAvailableClientName,
withClientNameReservation: withClientNameReservation,
requirePermission: function (permissionKey, options) {
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
diff --git a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js
index b64945a..815bb9e 100644
--- a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js
+++ b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js
@@ -68,7 +68,7 @@ function registerRecurringDataSourceRefreshes(options) {
category: TASK.category,
intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit),
metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name },
- run: function () { return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); }
+ run: function () { return refreshWeatherLocation(pool, common, location.id, null, options.notifyPlayerScreens); }
});
});
})();
diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js
index 0063ccc..f7c8c36 100644
--- a/src/web/lib/data-source-refresh.js
+++ b/src/web/lib/data-source-refresh.js
@@ -1,3 +1,5 @@
+// Refresh data sources and notify only the screens whose rendered data changed.
+
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
const slideIds = [];
@@ -106,6 +108,24 @@ function hasApiSourceChanged(apiSource, responseDetails) {
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
}
+function isDifferentHour(previousPulledAt, currentPulledAt) {
+ const previous = new Date(previousPulledAt);
+ const current = new Date(currentPulledAt);
+ if (!Number.isFinite(previous.getTime())) {
+ return false;
+ }
+
+ return previous.getFullYear() !== current.getFullYear()
+ || previous.getMonth() !== current.getMonth()
+ || previous.getDate() !== current.getDate()
+ || previous.getHours() !== current.getHours();
+}
+
+function hasWeatherLocationChanged(location, responseDetails, refreshedAt) {
+ return normalizeSnapshotValue(location && location.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson)
+ || isDifferentHour(location && location.last_pulled_at, refreshedAt);
+}
+
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection();
try {
@@ -169,16 +189,17 @@ async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId
pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.');
}
+ const refreshedAt = new Date();
await connection.beginTransaction();
await connection.query(
'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?',
- [new Date(), pullError || null, result ? result.responseJson : null, actorId, location.id]
+ [refreshedAt, pullError || null, result ? result.responseJson : null, actorId, location.id]
);
await connection.commit();
if (pullError) {
console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError);
- } else if (typeof notifyAffectedScreens === 'function') {
+ } else if (hasWeatherLocationChanged(location, result, refreshedAt) && typeof notifyPlayerScreens === 'function') {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id);
} catch (notifyError) {
diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js
index 1ffd552..e0d3425 100644
--- a/src/web/lib/media/upload-sync.js
+++ b/src/web/lib/media/upload-sync.js
@@ -389,6 +389,9 @@ function createUploadSyncService(options) {
}
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
+ if (String(uploadPath || '').startsWith('/media/player-cache/')) {
+ continue;
+ }
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -432,10 +435,6 @@ function createUploadSyncService(options) {
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
- if (!relativeDir && entryName === 'player-cache') {
- continue;
- }
-
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
@@ -445,7 +444,11 @@ function createUploadSyncService(options) {
continue;
}
- uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
+ const normalizedRelativePath = nextRelativePath.replace(/\\/g, '/');
+ if (normalizedRelativePath.startsWith('player-cache/') && !normalizedRelativePath.startsWith('player-cache/remote-images/')) {
+ continue;
+ }
+ uploadPaths.push((normalizedRelativePath.startsWith('player-cache/') ? '/media/' : '/media/uploads/') + normalizedRelativePath);
}
}
@@ -930,6 +933,12 @@ function createUploadSyncService(options) {
});
});
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir);
+ const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
+ generatedCachePaths.filter(function (uploadPath) {
+ return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
+ }).forEach(function (uploadPath) {
+ uploadRefs.add(uploadPath);
+ });
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
@@ -957,8 +966,12 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
- if (operation.nextUploadRefs.length) {
- await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
+ const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir);
+ const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) {
+ return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
+ }));
+ if (nextUploadRefs.length) {
+ await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
}
if (operation.previousUploadRefs.length) {
diff --git a/src/web/lib/notify-player-screens.js b/src/web/lib/notify-player-screens.js
index 7c408b2..a2c5f9c 100644
--- a/src/web/lib/notify-player-screens.js
+++ b/src/web/lib/notify-player-screens.js
@@ -1,4 +1,6 @@
-function createNotifyPlayerScreens(forwardPlayerCommand) {
+// Route screen refresh commands to the physical player hosting each screen.
+
+function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) {
if (typeof forwardPlayerCommand !== 'function') {
throw new Error('createNotifyPlayerScreens requires a player command sender.');
}
@@ -12,12 +14,29 @@ function createNotifyPlayerScreens(forwardPlayerCommand) {
return Promise.resolve(0);
}
- return Promise.allSettled(uniqueSlugs.map(function (slug) {
+ return Promise.allSettled(uniqueSlugs.map(async function (slug) {
+ // A screen may have connections on multiple players after a remote move or reconnect.
+ if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') {
+ const screenState = await getScreenConnections(slug);
+ const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : [];
+ const playerBaseUrls = Array.from(new Set(connections.map(function (connection) {
+ return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
+ }).filter(Boolean)));
+ if (playerBaseUrls.length) {
+ const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function'
+ ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); }))
+ : playerBaseUrls;
+ return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
+ return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh');
+ }));
+ }
+ }
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) {
- return results.filter(function (result) {
- return result.status === 'fulfilled';
+ const successful = results.filter(function (result) {
+ return result.status === 'fulfilled' && (!result.value || result.value.ok !== false);
}).length;
+ return successful;
});
};
}
diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js
index 418f019..eb8d510 100644
--- a/src/web/lib/player-actions.js
+++ b/src/web/lib/player-actions.js
@@ -1,3 +1,5 @@
+// Commands and synchronization actions forwarded from the web app to players.
+
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
@@ -215,6 +217,31 @@ function createPlayerActionService(options) {
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
}
+ async function resolvePlayerBaseUrlForPublicUrl(playerPublicBaseUrl) {
+ const normalizedPublicBaseUrl = normalizeBaseUrl(playerPublicBaseUrl);
+ if (!normalizedPublicBaseUrl) {
+ return '';
+ }
+
+ try {
+ const players = await fetchPlayerRegistrations(pool);
+ const matchedPlayer = (Array.isArray(players) ? players : []).find(function (player) {
+ return normalizeBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
+ });
+ const registeredInternalBaseUrl = normalizeBaseUrl(matchedPlayer && matchedPlayer.internal_base_url);
+ if (registeredInternalBaseUrl) {
+ return registeredInternalBaseUrl;
+ }
+ } catch (_error) {
+ }
+
+ if (isLocalLikeBaseUrl(normalizedPublicBaseUrl) && configuredPlayerInternalBaseUrl) {
+ return configuredPlayerInternalBaseUrl;
+ }
+
+ return normalizedPublicBaseUrl;
+ }
+
async function forwardAnnouncementRefresh(slug) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([
@@ -367,6 +394,7 @@ function createPlayerActionService(options) {
return {
forwardPlayerCommand: forwardPlayerCommand,
+ resolvePlayerBaseUrlForPublicUrl: resolvePlayerBaseUrlForPublicUrl,
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js
index 25495ab..dde8a14 100644
--- a/src/web/lib/region-scripts.js
+++ b/src/web/lib/region-scripts.js
@@ -1,3 +1,5 @@
+// Load and normalize browser region scripts for server-rendered player pages.
+
const fs = require('fs');
const path = require('path');
diff --git a/src/web/lib/web-startup.js b/src/web/lib/web-startup.js
index 2e5d0ae..86f60b8 100644
--- a/src/web/lib/web-startup.js
+++ b/src/web/lib/web-startup.js
@@ -1,3 +1,5 @@
+// Start background services, scheduled tasks, and web application dependencies.
+
async function initializeWebServer(options) {
const common = options && options.common;
const pool = options && options.pool;
diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js
index 3071129..8c9497b 100644
--- a/src/web/public/js/admin/admin-page.js
+++ b/src/web/public/js/admin/admin-page.js
@@ -969,8 +969,11 @@
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
- if (button) {
- button.classList.add('d-none');
+ if (collapseButton) {
+ collapseButton.classList.add('d-none');
+ }
+ if (expandButton) {
+ expandButton.classList.add('d-none');
}
return;
}
@@ -979,8 +982,11 @@
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
- if (button) {
- button.classList.add('d-none');
+ if (collapseButton) {
+ collapseButton.classList.add('d-none');
+ }
+ if (expandButton) {
+ expandButton.classList.add('d-none');
}
return;
}
diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js
index 5a0c33b..d0dd622 100644
--- a/src/web/public/js/dashboard/dashboard-page.js
+++ b/src/web/public/js/dashboard/dashboard-page.js
@@ -35,7 +35,7 @@
function getSelectedScreenClients(state) {
var select = getScreenCommandSelect();
var selectedSlug = select ? String(select.value || '').trim() : '';
- var clients = Array.isArray(state && state.clients) ? state.clients : [];
+ var clients = state && Array.isArray(state.clients) ? state.clients : [];
if (!selectedSlug || selectedSlug === '__all__') {
return clients;
@@ -336,15 +336,16 @@
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
+ var commandTargetId = client.clientId || client.id || '';
return [
'',
- '',
+ '',
'',
- '',
- '',
- '',
- '',
+ '',
+ '',
+ '',
+ '',
'
'
].join('');
}
@@ -368,7 +369,7 @@
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
- var connectionId = String(row.getAttribute('data-client-id') || '').trim();
+ var connectionId = String(row.getAttribute('data-client-client-id') || row.getAttribute('data-client-id') || '').trim();
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
@@ -428,7 +429,7 @@
}
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
if (connectionInput) {
- connectionInput.value = client.id || '';
+ connectionInput.value = client.clientId || client.id || '';
}
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
if (playerBaseUrlInput) {
@@ -447,7 +448,7 @@
if (reloadForm) {
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
if (reloadInput) {
- reloadInput.value = client.id || '';
+ reloadInput.value = client.clientId || client.id || '';
}
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
if (reloadPlayerBaseUrlInput) {
@@ -483,7 +484,7 @@
}
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
if (blackoutConnectionInput) {
- blackoutConnectionInput.value = client.id || '';
+ blackoutConnectionInput.value = client.clientId || client.id || '';
}
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
if (blackoutPlayerBaseUrlInput) {
@@ -508,7 +509,7 @@
}
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
if (previousConnectionInput) {
- previousConnectionInput.value = client.id || '';
+ previousConnectionInput.value = client.clientId || client.id || '';
}
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
if (previousPlayerBaseUrlInput) {
@@ -533,7 +534,7 @@
}
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
if (nextConnectionInput) {
- nextConnectionInput.value = client.id || '';
+ nextConnectionInput.value = client.clientId || client.id || '';
}
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
if (nextPlayerBaseUrlInput) {
diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js
index c6ec0b4..5eb3214 100644
--- a/src/web/public/js/slides/slide-form.js
+++ b/src/web/public/js/slides/slide-form.js
@@ -93,8 +93,22 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
}
var color = String(backgroundColor || '#111111').trim() || '#111111';
+ var gradient = '';
+ try {
+ var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
+ if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
+ var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (stopColor, index, colors) { return { color: stopColor, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
+ stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
+ if (stops.length >= 2) {
+ var angle = Number(gradientData.angle);
+ gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
+ }
+ }
+ } catch (_error) {
+ gradient = '';
+ }
element.style.backgroundColor = color;
- element.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : 'none';
+ element.style.backgroundImage = [backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : '', gradient].filter(Boolean).join(',') || 'none';
element.style.backgroundPosition = 'center';
element.style.backgroundSize = 'cover';
element.style.backgroundRepeat = 'no-repeat';
@@ -110,7 +124,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
- if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
+ if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
diff --git a/src/web/public/js/web-ui-helpers.js b/src/web/public/js/web-ui-helpers.js
index 4632d1f..62bae13 100644
--- a/src/web/public/js/web-ui-helpers.js
+++ b/src/web/public/js/web-ui-helpers.js
@@ -187,163 +187,8 @@
return ip;
}
- window.escapeHtml = escapeHtml;
initializeUrlValidation();
- var defaultQrOptions = window.defaultQrOptions || {
- width: 1000,
- height: 1000,
- type: 'canvas',
- margin: 10,
- qrOptions: {},
- dotsOptions: {
- color: '#000000',
- type: 'square'
- },
- cornersSquareOptions: {
- color: '#000000',
- type: 'square'
- },
- cornersDotOptions: {
- color: '#000000',
- type: 'square'
- },
- backgroundOptions: {
- color: '#ffffff'
- }
- };
- var qrDotTypes = window.qrDotTypes || [
- { value: 'square', label: 'Square' },
- { value: 'dots', label: 'Dots' },
- { value: 'rounded', label: 'Rounded' },
- { value: 'extra-rounded', label: 'Extra rounded' },
- { value: 'classy', label: 'Classy' },
- { value: 'classy-rounded', label: 'Classy rounded' }
- ];
- var qrCornerSquareTypes = window.qrCornerSquareTypes || [
- { value: 'square', label: 'Square' },
- { value: 'dot', label: 'Dot' },
- { value: 'rounded', label: 'Rounded' },
- { value: 'extra-rounded', label: 'Extra rounded' },
- { value: 'dots', label: 'Dots' },
- { value: 'classy', label: 'Classy' },
- { value: 'classy-rounded', label: 'Classy rounded' }
- ];
- var qrCornerDotTypes = window.qrCornerDotTypes || [
- { value: 'square', label: 'Square' },
- { value: 'dot', label: 'Dot' },
- { value: 'rounded', label: 'Rounded' },
- { value: 'extra-rounded', label: 'Extra rounded' },
- { value: 'classy', label: 'Classy' },
- { value: 'classy-rounded', label: 'Classy rounded' }
- ];
-
- function getOptionValue(option) {
- if (option && typeof option === 'object' && option.value !== undefined && option.value !== null) {
- return String(option.value).trim();
- }
-
- return String(option === undefined || option === null ? '' : option).trim();
- }
-
- function normalizeMarginValue(value, fallback) {
- var parsed = Number(value);
- return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback;
- }
-
- function normalizeHexColorValue(value, fallback) {
- var raw = String(value === undefined || value === null ? '' : value).trim();
- return raw || fallback;
- }
-
- function normalizeBooleanValue(value, fallback) {
- if (value === undefined || value === null || value === '') {
- return Boolean(fallback);
- }
- if (typeof value === 'boolean') {
- return value;
- }
- var text = String(value).trim().toLowerCase();
- if (text === 'true' || text === '1' || text === 'yes' || text === 'on') {
- return true;
- }
- if (text === 'false' || text === '0' || text === 'no' || text === 'off') {
- return false;
- }
- return Boolean(value);
- }
-
- function normalizeOptionValue(value, options, fallback) {
- var text = String(value === undefined || value === null ? '' : value).trim();
- for (var index = 0; index < options.length; index += 1) {
- if (getOptionValue(options[index]) === text) {
- return text;
- }
- }
-
- return fallback;
- }
-
- function normalizeRatioValue(value, fallback) {
- var parsed = Number(value);
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
- }
-
- function normalizeGradientTypeValue(value, fallback) {
- var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
- return text === 'radial' ? 'radial' : 'linear';
- }
-
- function normalizeGradientRotationValue(value, fallback) {
- var parsed = Number(value);
- return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
- }
-
- function normalizeColorModeValue(value, fallback) {
- var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
- return text === 'gradient' ? 'gradient' : 'single';
- }
-
- function normalizeColorValue(value, fallback) {
- return normalizeHexColorValue(value, fallback);
- }
-
- function renderSelectOptions(options, currentValue) {
- return options.map(function (option) {
- var optionValue = getOptionValue(option);
- var optionLabel = option && typeof option === 'object' && option.label !== undefined ? option.label : optionValue;
- return '';
- }).join('');
- }
-
- function normalizeSvgMarkup(svg) {
- var markup = String(svg || '').trim();
- if (!markup || markup.charAt(0) !== '<') {
- return markup;
- }
-
- return markup.replace(/^