Release v2.10.3
This commit is contained in:
@@ -2,11 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.10.2 - 2026-08-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed the weather forecast preview to show only its first-fetch message until a successful forecast is available.
|
- 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
|
## 2.10.1 - 2026-08-28
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ PULSE_SIGNAGE_SHARED_SECRET=""
|
|||||||
|
|
||||||
# Remote player
|
# Remote player
|
||||||
PLAYER_IDENTIFIER="player-remote"
|
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
|
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
|
||||||
|
|
||||||
# Remote bridge connectivity
|
# Remote bridge connectivity
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ services:
|
|||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
environment:
|
environment:
|
||||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
|
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
|
||||||
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
|
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: ${BRIDGE_PUBLIC_URL:?BRIDGE_PUBLIC_URL must be set to a routable bridge URL}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
|
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^149.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ const data = require('#src/data');
|
|||||||
const player = require('#src/player/render');
|
const player = require('#src/player/render');
|
||||||
const listQuery = require('#src/web/lib/list-query');
|
const listQuery = require('#src/web/lib/list-query');
|
||||||
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
|
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
|
||||||
|
const { findAvailableClientName } = require('#src/data/client-name-check');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createPool: dbCommon.createPool,
|
createPool: dbCommon.createPool,
|
||||||
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
|
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
|
||||||
|
touchOnboardingDeviceLastSeen: dbCommon.touchOnboardingDeviceLastSeen,
|
||||||
|
findAvailableClientName: findAvailableClientName,
|
||||||
ensureSchema: db.ensureSchema,
|
ensureSchema: db.ensureSchema,
|
||||||
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
|
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
|
||||||
slugify: data.slugify,
|
slugify: data.slugify,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Load and expose the announcement icon catalog used by the editor and player.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Application-wide settings defaults and persistence helpers.
|
||||||
|
|
||||||
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
|
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
|
||||||
|
|
||||||
const SETTING_DEFINITIONS = [
|
const SETTING_DEFINITIONS = [
|
||||||
@@ -35,13 +37,13 @@ const SETTING_DEFINITIONS = [
|
|||||||
{ key: 'announcements.suggested_icons', type: 'string_array', defaultValue: DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice() },
|
{ 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_slide_duration_seconds', type: 'integer', min: 1, defaultValue: 10 },
|
||||||
{ key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true },
|
{ key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true },
|
||||||
{ key: 'player.skip_unavailable_rtmp', 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_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.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_value', type: 'integer', min: 1, defaultValue: 60 },
|
||||||
,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
|
{ 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.open_meteo_api_key', type: 'string', defaultValue: '' },
|
||||||
,{ key: 'weather.pirate_weather_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) {
|
const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Audit event definitions and data access helpers for administrative activity.
|
||||||
|
|
||||||
const AUDIT_EVENT_CATEGORIES = Object.freeze({
|
const AUDIT_EVENT_CATEGORIES = Object.freeze({
|
||||||
AUTHENTICATION: 'authentication',
|
AUTHENTICATION: 'authentication',
|
||||||
SECURITY: 'security',
|
SECURITY: 'security',
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
|
|||||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||||
const live = collectLiveConnections(liveConnections);
|
const live = collectLiveConnections(liveConnections);
|
||||||
const lowerName = normalizedName.toLowerCase();
|
const lowerName = normalizedName.toLowerCase();
|
||||||
|
const liveDeviceIds = new Set(live.map(function (connection) {
|
||||||
|
return normalizeDeviceId(connection && (connection.deviceId || connection.clientId));
|
||||||
|
}).filter(Boolean));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (pool) {
|
if (pool) {
|
||||||
@@ -32,12 +35,13 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
|
|||||||
WHERE client_name IS NOT NULL
|
WHERE client_name IS NOT NULL
|
||||||
AND TRIM(client_name) <> ''
|
AND TRIM(client_name) <> ''
|
||||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||||
AND device_id <> ?
|
AND device_id <> ?`,
|
||||||
LIMIT 1`,
|
|
||||||
[normalizedName, normalizedDeviceId]
|
[normalizedName, normalizedDeviceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deviceRows.length) {
|
if ((deviceRows || []).some(function (row) {
|
||||||
|
return liveDeviceIds.has(normalizeDeviceId(row && row.device_id));
|
||||||
|
})) {
|
||||||
return false;
|
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) {
|
function buildClientNameLockName(clientName) {
|
||||||
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
||||||
}
|
}
|
||||||
@@ -116,5 +136,6 @@ module.exports = {
|
|||||||
normalizeDeviceId: normalizeDeviceId,
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
collectLiveConnections: collectLiveConnections,
|
collectLiveConnections: collectLiveConnections,
|
||||||
isClientNameAvailable: isClientNameAvailable,
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
findAvailableClientName: findAvailableClientName,
|
||||||
withClientNameReservation: withClientNameReservation
|
withClientNameReservation: withClientNameReservation
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Persistent player registration and heartbeat helpers shared by the web app and bridge.
|
||||||
|
|
||||||
function normalizeDeviceId(value) {
|
function normalizeDeviceId(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// QR code generation helpers for player onboarding and administrative links.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||||
const QR_PNG_WIDTH = 2048;
|
const QR_PNG_WIDTH = 2048;
|
||||||
|
|||||||
+25
-3
@@ -1,3 +1,5 @@
|
|||||||
|
// Database pool setup and lifecycle maintenance for persisted onboarding devices.
|
||||||
|
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
function createPool() {
|
function createPool() {
|
||||||
@@ -15,14 +17,34 @@ function createPool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pruneStaleOnboardingDevices(pool) {
|
async function pruneStaleOnboardingDevices(pool) {
|
||||||
|
// Uncompleted pairings expire quickly; completed bindings use their persisted heartbeat instead.
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`DELETE FROM d_onboarding_devices
|
`DELETE FROM d_onboarding_devices
|
||||||
WHERE screen_id IS NULL
|
WHERE (screen_id IS NULL
|
||||||
AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
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 = {
|
module.exports = {
|
||||||
createPool,
|
createPool,
|
||||||
pruneStaleOnboardingDevices
|
pruneStaleOnboardingDevices,
|
||||||
|
touchOnboardingDeviceLastSeen
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Schema initialization, migration execution, and database bootstrap helpers.
|
||||||
|
|
||||||
const { version: appVersion } = require('#root/package.json');
|
const { version: appVersion } = require('#root/package.json');
|
||||||
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
|
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
|
||||||
|
|
||||||
@@ -331,6 +333,7 @@ async function ensureSchema(pool, options) {
|
|||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
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
|
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
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Ordered database migrations kept independent from the application version.
|
||||||
|
|
||||||
const { version: appVersion } = require('#root/package.json');
|
const { version: appVersion } = require('#root/package.json');
|
||||||
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
||||||
const APP_STATE_TABLE = 'o_app_state';
|
const APP_STATE_TABLE = 'o_app_state';
|
||||||
@@ -552,6 +554,13 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
run: async function (pool) {
|
run: async function (pool) {
|
||||||
await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+41
-45
@@ -1,3 +1,5 @@
|
|||||||
|
// Thin-client bridge for player registration, snapshots, commands, and heartbeats.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -8,7 +10,6 @@ const common = require('../common');
|
|||||||
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||||
const { createPlayerPlaylistService } = require('../player/playlist');
|
const { createPlayerPlaylistService } = require('../player/playlist');
|
||||||
const { buildThumbnailPreviewData } = require('../player/thumbnail-preview');
|
|
||||||
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
|
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
|
||||||
const { createStyledQrCodeSvg } = require('../data/qr-code');
|
const { createStyledQrCodeSvg } = require('../data/qr-code');
|
||||||
const { verifyPageAuthToken } = require('#src/request-auth');
|
const { verifyPageAuthToken } = require('#src/request-auth');
|
||||||
@@ -458,7 +459,21 @@ async function start() {
|
|||||||
|
|
||||||
function storeScreenSnapshot(slug, connections, deviceIds) {
|
function storeScreenSnapshot(slug, connections, deviceIds) {
|
||||||
const key = String(slug || '').trim();
|
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) {
|
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
|
||||||
return normalizeDeviceId(value);
|
return normalizeDeviceId(value);
|
||||||
}).filter(Boolean) : [];
|
}).filter(Boolean) : [];
|
||||||
@@ -654,6 +669,16 @@ async function start() {
|
|||||||
}, response && typeof response === 'object' ? response : {});
|
}, 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({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
screenSlug: slug,
|
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) {
|
app.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const deviceId = normalizeDeviceId(req.params.deviceId);
|
const deviceId = normalizeDeviceId(req.params.deviceId);
|
||||||
@@ -989,7 +972,14 @@ async function start() {
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1011,12 @@ async function start() {
|
|||||||
publicBaseUrl: payload.publicBaseUrl,
|
publicBaseUrl: payload.publicBaseUrl,
|
||||||
internalBaseUrl: payload.internalBaseUrl
|
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 }));
|
socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player }));
|
||||||
return;
|
return;
|
||||||
|
|||||||
+33
-5
@@ -1,3 +1,5 @@
|
|||||||
|
// Player application bootstrap, media routes, websocket runtime, and onboarding wiring.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
@@ -10,8 +12,10 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
|
|||||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||||
const { registerPlayerRoutes } = require('./player/routes');
|
const { registerPlayerRoutes } = require('./player/routes');
|
||||||
|
const { getPlayerRuntimeScripts } = require('./player/render-helpers');
|
||||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
|
const { withClientNameReservation } = require('#src/data/client-name-check');
|
||||||
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||||
|
|
||||||
|
|
||||||
@@ -52,11 +56,28 @@ async function start() {
|
|||||||
thinClientSocket.send(JSON.stringify({
|
thinClientSocket.send(JSON.stringify({
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
deviceId: PLAYER_DEVICE_ID,
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
|
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
|
||||||
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
|
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
|
||||||
}));
|
}));
|
||||||
} catch (_error) {
|
} 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
|
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, {
|
registerPlayerRoutes(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
@@ -328,6 +357,7 @@ async function start() {
|
|||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
playerPlaylistService: playerPlaylistService,
|
playerPlaylistService: playerPlaylistService,
|
||||||
rtmpStreamService: rtmpStreamService,
|
rtmpStreamService: rtmpStreamService,
|
||||||
|
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists'),
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
||||||
playerDeviceId: PLAYER_DEVICE_ID,
|
playerDeviceId: PLAYER_DEVICE_ID,
|
||||||
@@ -391,7 +421,8 @@ async function start() {
|
|||||||
internalBaseUrl: PLAYER_INTERNAL_URL,
|
internalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
pairingCode: activePairingCode,
|
pairingCode: activePairingCode,
|
||||||
pairingCodes: activePairingCodes,
|
pairingCodes: activePairingCodes,
|
||||||
pairingSessions: activePairingSessions
|
pairingSessions: activePairingSessions,
|
||||||
|
connections: playerRuntime.snapshotAllConnections()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +470,7 @@ async function start() {
|
|||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
deviceId: PLAYER_DEVICE_ID,
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
slug: String(slug || '').trim(),
|
slug: String(slug || '').trim(),
|
||||||
connections: playerRuntime.snapshotConnections(slug)
|
connections: playerRuntime.snapshotConnections(slug)
|
||||||
}));
|
}));
|
||||||
@@ -565,10 +597,6 @@ async function start() {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
|
||||||
await common.pruneStaleOnboardingDevices(pool);
|
|
||||||
}
|
|
||||||
|
|
||||||
await onboardingStore.flushBindings(function (entry) {
|
await onboardingStore.flushBindings(function (entry) {
|
||||||
return commitDeviceBinding(
|
return commitDeviceBinding(
|
||||||
pool,
|
pool,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const crypto = require('crypto');
|
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 { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||||
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
|
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 screen = screenRows[0];
|
||||||
|
|
||||||
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
|
const selectedClientName = await findAvailableClientName(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||||
if (!available) {
|
if (!selectedClientName) {
|
||||||
const error = new Error('Client name already exists.');
|
const error = new Error('Client name already exists.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -153,9 +153,9 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
|||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE d_onboarding_devices
|
`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 = ?`,
|
WHERE device_id = ?`,
|
||||||
[normalizedClientName, screen.id, normalizedDeviceId]
|
[selectedClientName, screen.id, normalizedDeviceId]
|
||||||
);
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
`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 (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
||||||
)`,
|
)`,
|
||||||
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
|
[normalizedDeviceId, selectedClientName, screen.id, normalizedDeviceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||||
@@ -581,6 +581,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
}
|
}
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
pairingSessions.delete(deviceId);
|
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)}`;
|
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
|
||||||
return res.json(payload);
|
return res.json(payload);
|
||||||
@@ -600,6 +601,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
}
|
}
|
||||||
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||||
pairingSessions.delete(deviceId);
|
pairingSessions.delete(deviceId);
|
||||||
|
res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
|
||||||
res.json({
|
res.json({
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
clientName: status ? status.client_name : clientName,
|
clientName: status ? status.client_name : clientName,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||||
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,41 +1,11 @@
|
|||||||
|
<!-- Player page bootstrap and browser-side playback lifecycle. -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const slug = {{SLUG_JSON}};
|
const slug = {{SLUG_JSON}};
|
||||||
let initialData = {{INITIAL_DATA_JSON}};
|
let initialData = {{INITIAL_DATA_JSON}};
|
||||||
window.slug = slug;
|
window.slug = slug;
|
||||||
window.initialData = initialData;
|
window.initialData = initialData;
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
(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
|
|
||||||
};
|
|
||||||
}());
|
|
||||||
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
|
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
|
||||||
let currentPlaylistSignature = '';
|
let currentPlaylistSignature = '';
|
||||||
let currentPlaylistEtag = '';
|
let currentPlaylistEtag = '';
|
||||||
@@ -61,6 +31,7 @@
|
|||||||
let viewportRenderTimer = null;
|
let viewportRenderTimer = null;
|
||||||
let refreshRetryTimer = null;
|
let refreshRetryTimer = null;
|
||||||
let refreshRetryDelayMs = 0;
|
let refreshRetryDelayMs = 0;
|
||||||
|
let playlistRefreshTimer = null;
|
||||||
let commandClientId = null;
|
let commandClientId = null;
|
||||||
let screenWakeLock = null;
|
let screenWakeLock = null;
|
||||||
let screenWakeLockRequestPromise = null;
|
let screenWakeLockRequestPromise = null;
|
||||||
@@ -102,7 +73,12 @@
|
|||||||
|
|
||||||
// Render the empty-state message into the player root.
|
// Render the empty-state message into the player root.
|
||||||
function renderEmpty(message) {
|
function renderEmpty(message) {
|
||||||
app.innerHTML = '<div class="empty">' + escapeHtml(message) + '</div>';
|
var markup = '<div class="empty">' + escapeHtml(message) + '</div>';
|
||||||
|
if (currentPlaylistFadeBetweenSlides && typeof renderSlideMarkup === 'function') {
|
||||||
|
renderSlideMarkup(markup, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.innerHTML = markup;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Announce the player to the command websocket.
|
// Announce the player to the command websocket.
|
||||||
@@ -204,10 +180,19 @@
|
|||||||
showCurrent();
|
showCurrent();
|
||||||
refresh();
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
|
if (initialPlaylistSnapshot && applyPlaylistSnapshot(initialPlaylistSnapshot)) {
|
||||||
|
currentPlaylistSkipUnavailableRtmp = Boolean(initialPlaylistSnapshot.skipUnavailableRtmp);
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
syncBlackoutState();
|
syncBlackoutState();
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
syncOfflineBanner();
|
syncOfflineBanner();
|
||||||
syncScreenWakeLock();
|
syncScreenWakeLock();
|
||||||
|
playlistRefreshTimer = window.setInterval(function () {
|
||||||
|
if (document.visibilityState !== 'hidden') {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
connectCommandSocket();
|
connectCommandSocket();
|
||||||
</script>
|
</script>
|
||||||
@@ -22,21 +22,6 @@ body.onboarding-page #app {
|
|||||||
display: none;
|
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 {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function getCurrentViewport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var slideOutroTimers = [];
|
var slideOutroTimers = [];
|
||||||
|
var commandHeartbeatTimer = null;
|
||||||
|
|
||||||
// Command websocket and player-state helpers.
|
// Command websocket and player-state helpers.
|
||||||
// Send the current playback state to the command websocket.
|
// Send the current playback state to the command websocket.
|
||||||
@@ -166,19 +167,11 @@ function getPlayerRegionModules() {
|
|||||||
return window.pulsePlayerRegionTypes.list();
|
return window.pulsePlayerRegionTypes.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThumbnailPreview() {
|
|
||||||
return Boolean(window.__pulseThumbnailPreview);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runRegionLifecycle(root, lifecycleName) {
|
function runRegionLifecycle(root, lifecycleName) {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlayerRegionModules().forEach(function (entry) {
|
getPlayerRegionModules().forEach(function (entry) {
|
||||||
var module = entry && entry.definition ? entry.definition : null;
|
var module = entry && entry.definition ? entry.definition : null;
|
||||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||||
@@ -276,9 +269,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
syncRtmpRegions(root);
|
syncRtmpRegions(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isThumbnailPreview()) {
|
|
||||||
initializeRegionInstances(root);
|
initializeRegionInstances(root);
|
||||||
}
|
|
||||||
|
|
||||||
initializeRenderedVideoPlayback(root, delayMs);
|
initializeRenderedVideoPlayback(root, delayMs);
|
||||||
|
|
||||||
@@ -330,13 +321,16 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var nextShell = document.createElement('div');
|
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.style.opacity = '0';
|
||||||
nextShell.innerHTML = markup;
|
nextShell.innerHTML = markup;
|
||||||
|
|
||||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||||
app.innerHTML = '';
|
app.innerHTML = '';
|
||||||
nextShell.style.opacity = '1';
|
nextShell.style.opacity = '1';
|
||||||
|
nextShell.classList.remove('slide-shell-entering');
|
||||||
|
nextShell.classList.add('is-visible');
|
||||||
app.appendChild(nextShell);
|
app.appendChild(nextShell);
|
||||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||||
return nextShell;
|
return nextShell;
|
||||||
@@ -345,6 +339,9 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (!previousShell.classList.contains('slide-shell')) {
|
if (!previousShell.classList.contains('slide-shell')) {
|
||||||
previousShell.classList.add('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';
|
previousShell.style.opacity = '1';
|
||||||
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
||||||
|
|
||||||
@@ -352,6 +349,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
window.requestAnimationFrame(function () {
|
window.requestAnimationFrame(function () {
|
||||||
nextShell.style.opacity = '1';
|
nextShell.style.opacity = '1';
|
||||||
previousShell.style.opacity = '0';
|
previousShell.style.opacity = '0';
|
||||||
|
nextShell.classList.remove('slide-shell-entering');
|
||||||
|
nextShell.classList.add('is-visible');
|
||||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,7 +359,10 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
previousShell.parentNode.removeChild(previousShell);
|
previousShell.parentNode.removeChild(previousShell);
|
||||||
}
|
}
|
||||||
if (nextShell) {
|
if (nextShell) {
|
||||||
|
nextShell.style.zIndex = '1';
|
||||||
nextShell.style.opacity = '1';
|
nextShell.style.opacity = '1';
|
||||||
|
nextShell.classList.remove('slide-shell-entering');
|
||||||
|
nextShell.classList.add('is-visible');
|
||||||
}
|
}
|
||||||
slideTransitionTimer = null;
|
slideTransitionTimer = null;
|
||||||
}, slideFadeDurationMs);
|
}, slideFadeDurationMs);
|
||||||
@@ -537,14 +539,28 @@ function handleCommandMessage(rawMessage) {
|
|||||||
handleClientIdConflict();
|
handleClientIdConflict();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (payload && payload.type === 'client-name-updated') {
|
||||||
|
if (payload.clientName) {
|
||||||
|
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!payload || payload.type !== 'command') {
|
if (!payload || payload.type !== 'command') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.requestId && commandSocket && commandSocket.readyState === WebSocket.OPEN) {
|
||||||
|
commandSocket.send(JSON.stringify({
|
||||||
|
type: 'command-ack',
|
||||||
|
requestId: payload.requestId,
|
||||||
|
ok: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
switch (payload.command) {
|
switch (payload.command) {
|
||||||
case 'refresh':
|
case 'refresh':
|
||||||
refresh();
|
refresh(true);
|
||||||
return;
|
return;
|
||||||
case 'setclientname':
|
case 'setclientname':
|
||||||
if (payload.clientName) {
|
if (payload.clientName) {
|
||||||
@@ -602,7 +618,6 @@ function handleCommandMessage(rawMessage) {
|
|||||||
function handleClientIdConflict() {
|
function handleClientIdConflict() {
|
||||||
var replacementClientId = regenerateCommandClientId();
|
var replacementClientId = regenerateCommandClientId();
|
||||||
var onboardingUrl = new URL('/', window.location.origin);
|
var onboardingUrl = new URL('/', window.location.origin);
|
||||||
onboardingUrl.searchParams.set('clientId', replacementClientId);
|
|
||||||
window.location.replace(onboardingUrl.toString());
|
window.location.replace(onboardingUrl.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +644,12 @@ function connectCommandSocket() {
|
|||||||
commandSocket = socket;
|
commandSocket = socket;
|
||||||
|
|
||||||
socket.onopen = function () {
|
socket.onopen = function () {
|
||||||
|
if (commandHeartbeatTimer) {
|
||||||
|
window.clearInterval(commandHeartbeatTimer);
|
||||||
|
}
|
||||||
|
commandHeartbeatTimer = window.setInterval(function () {
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
}, 60 * 1000);
|
||||||
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
||||||
syncOnboardingClientNameFromServer(socket).then(function () {
|
syncOnboardingClientNameFromServer(socket).then(function () {
|
||||||
sendCommandState(socket);
|
sendCommandState(socket);
|
||||||
@@ -643,6 +664,13 @@ function connectCommandSocket() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = function (event) {
|
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;
|
commandSocket = null;
|
||||||
if (event && event.code === 4009) {
|
if (event && event.code === 4009) {
|
||||||
handleClientIdConflict();
|
handleClientIdConflict();
|
||||||
@@ -651,7 +679,10 @@ function connectCommandSocket() {
|
|||||||
scheduleCommandReconnect();
|
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 {
|
try {
|
||||||
socket.close();
|
socket.close();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Show or hide the offline status banner.
|
// Show or hide the offline status banner.
|
||||||
function setOfflineBannerVisible(visible, message) {
|
function setOfflineBannerVisible(visible, message) {
|
||||||
if (window.__pulseThumbnailPreview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var normalizedVisible = Boolean(visible);
|
var normalizedVisible = Boolean(visible);
|
||||||
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
||||||
if (normalizedVisible) {
|
if (normalizedVisible) {
|
||||||
@@ -41,9 +38,6 @@ function setOfflineBannerVisible(visible, message) {
|
|||||||
|
|
||||||
// Update the offline banner based on connectivity or playlist availability.
|
// Update the offline banner based on connectivity or playlist availability.
|
||||||
function syncOfflineBanner() {
|
function syncOfflineBanner() {
|
||||||
if (window.__pulseThumbnailPreview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.navigator.onLine) {
|
if (!window.navigator.onLine) {
|
||||||
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
||||||
return;
|
return;
|
||||||
@@ -63,9 +57,6 @@ function clearRefreshRetry() {
|
|||||||
|
|
||||||
// Retry playlist refresh with a short backoff while the player is offline.
|
// Retry playlist refresh with a short backoff while the player is offline.
|
||||||
function scheduleRefreshRetry() {
|
function scheduleRefreshRetry() {
|
||||||
if (window.__pulseThumbnailPreview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (refreshRetryTimer) {
|
if (refreshRetryTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Render the slide at the requested index within the active set.
|
// 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 : [];
|
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
if (!availableSlides.length) {
|
if (!availableSlides.length) {
|
||||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
@@ -53,7 +53,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
|||||||
|
|
||||||
index = currentIndex;
|
index = currentIndex;
|
||||||
var markup = buildSlideMarkup(slide);
|
var markup = buildSlideMarkup(slide);
|
||||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
renderSlideMarkup(markup, !(options && options.skipFade) && currentPlaylistFadeBetweenSlides);
|
||||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||||
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ function applyPendingPlaylistUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render the current active slide or the empty state.
|
// Render the current active slide or the empty state.
|
||||||
function showCurrent() {
|
function showCurrent(options) {
|
||||||
clearSlideTimer();
|
clearSlideTimer();
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
syncWebpagePreloads(activeSlides, index);
|
syncWebpagePreloads(activeSlides, index);
|
||||||
@@ -106,7 +106,7 @@ function showCurrent() {
|
|||||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void renderSlideAtIndex(activeSlides, index);
|
void renderSlideAtIndex(activeSlides, index, options);
|
||||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ function handleRtmpPlaybackFailure(message, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the latest playlist and queue any updates.
|
// Fetch the latest playlist and queue any updates.
|
||||||
function refresh() {
|
function refresh(applyImmediately) {
|
||||||
var request = new XMLHttpRequest();
|
var request = new XMLHttpRequest();
|
||||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||||
request.open('GET', url, true);
|
request.open('GET', url, true);
|
||||||
@@ -167,6 +167,7 @@ function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.status === 304) {
|
if (request.status === 304) {
|
||||||
|
logDebug('Playlist refresh completed with no changes.');
|
||||||
markRefreshHealthy();
|
markRefreshHealthy();
|
||||||
setOfflineBannerVisible(false);
|
setOfflineBannerVisible(false);
|
||||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
@@ -192,6 +193,7 @@ function refresh() {
|
|||||||
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
||||||
const data = JSON.parse(request.responseText || '{}');
|
const data = JSON.parse(request.responseText || '{}');
|
||||||
const nextSignature = getPlaylistRevision(data);
|
const nextSignature = getPlaylistRevision(data);
|
||||||
|
logDebug('Playlist refresh completed.', 'Revision: ' + nextSignature);
|
||||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
@@ -254,6 +256,11 @@ function refresh() {
|
|||||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
};
|
};
|
||||||
|
if (applyImmediately) {
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
|
showCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
||||||
applyPendingPlaylistUpdate();
|
applyPendingPlaylistUpdate();
|
||||||
showCurrent();
|
showCurrent();
|
||||||
@@ -268,6 +275,11 @@ function refresh() {
|
|||||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
};
|
};
|
||||||
|
if (applyImmediately) {
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
|
showCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
logDebug('Playlist update detected; applying on next slide transition.');
|
logDebug('Playlist update detected; applying on next slide transition.');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
logDebug(
|
logDebug(
|
||||||
|
|||||||
@@ -129,10 +129,6 @@ function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
||||||
if (window.__pulseThumbnailPreview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
|
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
|
||||||
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
|
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
|
||||||
return;
|
return;
|
||||||
@@ -236,6 +232,7 @@ function loadPlaylistSnapshot() {
|
|||||||
slides: parsed.slides.map(normalizeSlide),
|
slides: parsed.slides.map(normalizeSlide),
|
||||||
signature: String(parsed.signature || ''),
|
signature: String(parsed.signature || ''),
|
||||||
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
|
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
|
||||||
|
skipUnavailableRtmp: Boolean(parsed.skipUnavailableRtmp),
|
||||||
etag: String(parsed.etag || '')
|
etag: String(parsed.etag || '')
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ function getRegionAnimationPhaseTimingMs(root, phase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRegionAnimationPhaseTimings(root, phase) {
|
function getRegionAnimationPhaseTimings(root, phase) {
|
||||||
if (!root || isThumbnailPreview()) {
|
if (!root) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ function getRegionAnimationPhaseTimings(root, phase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playRegionAnimation(element, phase) {
|
function playRegionAnimation(element, phase) {
|
||||||
if (!element || isThumbnailPreview()) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ function playRegionAnimation(element, phase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playRegionAnimations(root, phase) {
|
function playRegionAnimations(root, phase) {
|
||||||
if (!root || isThumbnailPreview()) {
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +797,7 @@ function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGrad
|
|||||||
var gradient = '';
|
var gradient = '';
|
||||||
try {
|
try {
|
||||||
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
|
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 }; });
|
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);
|
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) {
|
if (stops.length >= 2) {
|
||||||
@@ -975,7 +975,7 @@ function notifyVideoRegionSourceReady() {
|
|||||||
}
|
}
|
||||||
slideMarkupCache = Object.create(null);
|
slideMarkupCache = Object.create(null);
|
||||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||||
showCurrent();
|
showCurrent({ skipFade: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// RTMP region markup and playback lifecycle hooks.
|
||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
var registry = window.pulsePlayerRegionTypes;
|
||||||
|
|
||||||
function renderRtmpRegion(region, regionContent) {
|
function renderRtmpRegion(region, regionContent) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Assemble player HTML and inline runtime scripts from the server-side templates.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const announcementIcons = require('#src/data/announcement-icons');
|
const announcementIcons = require('#src/data/announcement-icons');
|
||||||
@@ -523,6 +525,32 @@ function getPlayerOnboardingFormScript() {
|
|||||||
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
|
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlayerRuntimeScripts() {
|
||||||
|
function getScriptBody(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/^\s*<script(?:\s[^>]*)?>/i, '')
|
||||||
|
.replace(/<\/script>\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
['region-registry', fs.readFileSync(path.join(__dirname, 'public', 'js', 'player-region-registry.js'), 'utf8').trim()],
|
||||||
|
['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()],
|
||||||
|
['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()],
|
||||||
|
['client-name', getScriptBody(getPlayerClientNameScript()())],
|
||||||
|
['offline', getScriptBody(getPlayerPageOfflineScript()())],
|
||||||
|
['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
|
||||||
|
['commands', getScriptBody(getPlayerPageCommandsScript()())],
|
||||||
|
['rendering', getScriptBody(getPlayerPageRenderingScript()())],
|
||||||
|
['playback', getScriptBody(getPlayerPagePlaybackScript()())],
|
||||||
|
['announcement-icons', getAnnouncementIconsDataScript()],
|
||||||
|
['announcement-data', getPlayerAnnouncementTemplatesDataScript()],
|
||||||
|
['announcement-templates', getScriptBody(getPlayerAnnouncementTemplatesScript())]
|
||||||
|
].concat(getPlayerRegionScriptPaths().map(function (filePath, index) {
|
||||||
|
return ['region-' + index, fs.readFileSync(filePath, 'utf8').trim()];
|
||||||
|
})).filter(function (entry) { return entry[1]; });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mediaKind: mediaKind,
|
mediaKind: mediaKind,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
@@ -556,5 +584,6 @@ module.exports = {
|
|||||||
getPlayerPageScript: getPlayerPageScript,
|
getPlayerPageScript: getPlayerPageScript,
|
||||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
|
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript,
|
||||||
|
getPlayerRuntimeScripts: getPlayerRuntimeScripts
|
||||||
};
|
};
|
||||||
+8
-13
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
const Handlebars = require('handlebars');
|
const Handlebars = require('handlebars');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
|
||||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
|
||||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||||
|
|
||||||
@@ -110,30 +109,26 @@ function renderOnboardingFormScript(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerPage(slug, initialData) {
|
function renderPlayerPage(slug, initialData) {
|
||||||
const onboardingScript = getPlayerClientNameScript()();
|
|
||||||
const offlineScript = getPlayerPageOfflineScript()();
|
|
||||||
const playlistScript = getPlayerPagePlaylistScript()();
|
|
||||||
const commandScript = getPlayerPageCommandsScript()();
|
|
||||||
const renderingScript = getPlayerPageRenderingScript()();
|
|
||||||
const playbackScript = getPlayerPagePlaybackScript()();
|
|
||||||
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||||
const template = getPlayerPageTemplate();
|
const template = getPlayerPageTemplate();
|
||||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||||
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
const bootstrapScript = getPlayerPageScript()({
|
||||||
const script = getPlayerPageScript()({
|
|
||||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||||
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
|
REGION_SCRIPTS: ''
|
||||||
});
|
});
|
||||||
|
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
|
||||||
|
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js"></script>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
return renderPage(template, {
|
return renderPage(template, {
|
||||||
title: 'Screen ' + slug,
|
title: 'Screen ' + slug,
|
||||||
bodyClass: bodyClass,
|
bodyClass: '',
|
||||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-63
@@ -5,7 +5,6 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { getPlayerPublicBaseUrl } = require('./onboarding');
|
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'];
|
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) {
|
function registerPlayerRoutes(app, options) {
|
||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
const common = options && options.common ? options.common : 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 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 bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
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;
|
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
|
||||||
|
|
||||||
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||||
@@ -71,6 +88,11 @@ function registerPlayerRoutes(app, options) {
|
|||||||
}
|
}
|
||||||
if (requestHeaders['x-pulse-client-id']) {
|
if (requestHeaders['x-pulse-client-id']) {
|
||||||
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
|
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) {
|
if (requestOptions.contentType) {
|
||||||
headers['content-type'] = requestOptions.contentType;
|
headers['content-type'] = requestOptions.contentType;
|
||||||
@@ -130,7 +152,7 @@ function registerPlayerRoutes(app, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function isClientAuthorizedForScreen(req, requestedSlug) {
|
async function isClientAuthorizedForScreen(req, requestedSlug) {
|
||||||
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
|
const clientId = getRequestClientId(req);
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -219,6 +241,32 @@ function registerPlayerRoutes(app, options) {
|
|||||||
return resolvedFilePath;
|
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', express.static(assetDir));
|
||||||
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
|
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
|
||||||
app.use('/media', express.static(mediaDir));
|
app.use('/media', express.static(mediaDir));
|
||||||
@@ -371,7 +419,7 @@ function registerPlayerRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/screen/:slug', async function (req, res, next) {
|
app.get('/screen/:slug', async function (req, res, next) {
|
||||||
if (onPlayerPublicBaseUrl) {
|
if (onPlayerPublicBaseUrl && !bridgeBaseUrl) {
|
||||||
try {
|
try {
|
||||||
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
|
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -387,21 +435,25 @@ function registerPlayerRoutes(app, options) {
|
|||||||
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
||||||
}).then(async function (response) {
|
}).then(async function (response) {
|
||||||
if (!response || response.status >= 400) {
|
if (!response || response.status >= 400) {
|
||||||
|
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||||
res.set('X-Player-Offline', '1');
|
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);
|
const data = await readJsonResponse(response);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||||
res.set('X-Player-Offline', '1');
|
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));
|
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||||
}).catch(function (error) {
|
}).catch(async function (error) {
|
||||||
if (!isBridgeFetchError(error)) {
|
if (!isBridgeFetchError(error)) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||||
res.set('X-Player-Offline', '1');
|
res.set('X-Player-Offline', '1');
|
||||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
res.send(common.renderPlayerPage(req.params.slug, snapshot));
|
||||||
});
|
});
|
||||||
return;
|
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) {
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
|
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
|
||||||
@@ -480,8 +477,14 @@ function registerPlayerRoutes(app, options) {
|
|||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
|
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||||
|
if (!snapshot) {
|
||||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
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);
|
res.status(response.status);
|
||||||
const etag = response.headers.get('etag');
|
const etag = response.headers.get('etag');
|
||||||
const cacheControl = response.headers.get('cache-control');
|
const cacheControl = response.headers.get('cache-control');
|
||||||
@@ -495,7 +498,12 @@ function registerPlayerRoutes(app, options) {
|
|||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
res.type(response.headers.get('content-type') || 'application/json');
|
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');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
|||||||
+128
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { WebSocketServer, WebSocket } = require('ws');
|
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 { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||||
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
|
|||||||
function createPlayerRuntime(options) {
|
function createPlayerRuntime(options) {
|
||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : 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'
|
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||||
? options.normalizeDeviceId
|
? options.normalizeDeviceId
|
||||||
: function (value) {
|
: function (value) {
|
||||||
@@ -30,7 +32,11 @@ function createPlayerRuntime(options) {
|
|||||||
const connectionsBySlug = new Map();
|
const connectionsBySlug = new Map();
|
||||||
const dashboardListenersBySlug = new Map();
|
const dashboardListenersBySlug = new Map();
|
||||||
const announcementListenersBySlug = new Map();
|
const announcementListenersBySlug = new Map();
|
||||||
|
const pendingCommandAcks = new Map();
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
const staleConnectionMs = Number(options && options.staleConnectionMs) > 0
|
||||||
|
? Number(options.staleConnectionMs)
|
||||||
|
: 3 * 60 * 1000;
|
||||||
|
|
||||||
function hasActiveClientId(clientId, currentConnection) {
|
function hasActiveClientId(clientId, currentConnection) {
|
||||||
const normalizedClientId = String(clientId || '').trim();
|
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) {
|
function getDashboardListenerBucket(slug) {
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -370,7 +427,10 @@ function createPlayerRuntime(options) {
|
|||||||
return 0;
|
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) {
|
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -382,10 +442,30 @@ function createPlayerRuntime(options) {
|
|||||||
payload.targetConnectionId = target.id;
|
payload.targetConnectionId = target.id;
|
||||||
payload.sentAt = new Date().toISOString();
|
payload.sentAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const requestId = String(payload.requestId || '').trim();
|
||||||
|
if (!requestId) {
|
||||||
target.socket.send(JSON.stringify(payload));
|
target.socket.send(JSON.stringify(payload));
|
||||||
return 1;
|
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 await acknowledgement;
|
||||||
|
}
|
||||||
|
|
||||||
async function broadcastCommand(slug, commandOrPayload) {
|
async function broadcastCommand(slug, commandOrPayload) {
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
if (!bucket || !bucket.size) {
|
if (!bucket || !bucket.size) {
|
||||||
@@ -527,6 +607,7 @@ function createPlayerRuntime(options) {
|
|||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
lastSeenAt: new Date()
|
lastSeenAt: new Date()
|
||||||
};
|
};
|
||||||
|
connection.isAlive = true;
|
||||||
const bucket = getConnectionBucket(slug);
|
const bucket = getConnectionBucket(slug);
|
||||||
|
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
@@ -536,7 +617,11 @@ function createPlayerRuntime(options) {
|
|||||||
|
|
||||||
bucket.set(connectionId, connection);
|
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();
|
connection.lastSeenAt = new Date();
|
||||||
let payload = null;
|
let payload = null;
|
||||||
try {
|
try {
|
||||||
@@ -546,6 +631,13 @@ function createPlayerRuntime(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!payload || payload.type !== 'state') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +653,26 @@ function createPlayerRuntime(options) {
|
|||||||
if (!connection.clientName && connection.clientId) {
|
if (!connection.clientName && connection.clientId) {
|
||||||
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.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||||
@@ -581,12 +693,24 @@ function createPlayerRuntime(options) {
|
|||||||
broadcastConnectionSnapshot(slug);
|
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);
|
removeConnection(slug, connectionId);
|
||||||
broadcastConnectionSnapshot(slug);
|
broadcastConnectionSnapshot(slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', function () {
|
socket.on('error', function () {
|
||||||
|
pendingCommandAcks.forEach(function (pendingAck, requestId) {
|
||||||
|
if (pendingAck.connection === connection) {
|
||||||
|
pendingAck.resolve(false);
|
||||||
|
pendingCommandAcks.delete(requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
removeConnection(slug, connectionId);
|
removeConnection(slug, connectionId);
|
||||||
broadcastConnectionSnapshot(slug);
|
broadcastConnectionSnapshot(slug);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildThumbnailPreviewData: buildThumbnailPreviewData,
|
|
||||||
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
|
|
||||||
};
|
|
||||||
+7
-1
@@ -67,7 +67,12 @@ async function start() {
|
|||||||
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
||||||
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
|
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
|
||||||
});
|
});
|
||||||
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
const notifyPlayerScreens = createNotifyPlayerScreens(
|
||||||
|
playerActionService.forwardPlayerCommand,
|
||||||
|
playerActionService.getScreenConnections,
|
||||||
|
playerActionService.forwardPlayerCommandToBaseUrl,
|
||||||
|
playerActionService.resolvePlayerBaseUrlForPublicUrl
|
||||||
|
);
|
||||||
|
|
||||||
// Centralized dashboard/player bootstrap.
|
// Centralized dashboard/player bootstrap.
|
||||||
const webBootstrap = createWebBootstrap({
|
const webBootstrap = createWebBootstrap({
|
||||||
@@ -165,6 +170,7 @@ async function start() {
|
|||||||
playerActionService: playerActionService,
|
playerActionService: playerActionService,
|
||||||
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
||||||
isClientNameAvailable: isClientNameAvailable,
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
findAvailableClientName: common.findAvailableClientName,
|
||||||
withClientNameReservation: withClientNameReservation,
|
withClientNameReservation: withClientNameReservation,
|
||||||
requirePermission: function (permissionKey, options) {
|
requirePermission: function (permissionKey, options) {
|
||||||
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
|
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
|||||||
category: TASK.category,
|
category: TASK.category,
|
||||||
intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit),
|
intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit),
|
||||||
metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name },
|
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); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Refresh data sources and notify only the screens whose rendered data changed.
|
||||||
|
|
||||||
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
|
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 [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
const slideIds = [];
|
const slideIds = [];
|
||||||
@@ -106,6 +108,24 @@ function hasApiSourceChanged(apiSource, responseDetails) {
|
|||||||
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
|
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) {
|
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
@@ -169,16 +189,17 @@ async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId
|
|||||||
pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.');
|
pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshedAt = new Date();
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
await connection.query(
|
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 = ?',
|
'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();
|
await connection.commit();
|
||||||
|
|
||||||
if (pullError) {
|
if (pullError) {
|
||||||
console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + 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 {
|
try {
|
||||||
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id);
|
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id);
|
||||||
} catch (notifyError) {
|
} catch (notifyError) {
|
||||||
|
|||||||
@@ -389,6 +389,9 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||||
|
if (String(uploadPath || '').startsWith('/media/player-cache/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -432,10 +435,6 @@ function createUploadSyncService(options) {
|
|||||||
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||||
const nextAbsolutePath = path.join(currentDir, entryName);
|
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||||
|
|
||||||
if (!relativeDir && entryName === 'player-cache') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory && entry.isDirectory()) {
|
if (entry.isDirectory && entry.isDirectory()) {
|
||||||
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||||
continue;
|
continue;
|
||||||
@@ -445,7 +444,11 @@ function createUploadSyncService(options) {
|
|||||||
continue;
|
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 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) {
|
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||||
queuePlayerUploadSync({
|
queuePlayerUploadSync({
|
||||||
type: 'put',
|
type: 'put',
|
||||||
@@ -957,8 +966,12 @@ function createUploadSyncService(options) {
|
|||||||
if (mode === 'playlist') {
|
if (mode === 'playlist') {
|
||||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||||
|
|
||||||
if (operation.nextUploadRefs.length) {
|
const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir);
|
||||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
|
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) {
|
if (operation.previousUploadRefs.length) {
|
||||||
|
|||||||
@@ -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') {
|
if (typeof forwardPlayerCommand !== 'function') {
|
||||||
throw new Error('createNotifyPlayerScreens requires a player command sender.');
|
throw new Error('createNotifyPlayerScreens requires a player command sender.');
|
||||||
}
|
}
|
||||||
@@ -12,12 +14,29 @@ function createNotifyPlayerScreens(forwardPlayerCommand) {
|
|||||||
return Promise.resolve(0);
|
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');
|
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||||
})).then(function (results) {
|
})).then(function (results) {
|
||||||
return results.filter(function (result) {
|
const successful = results.filter(function (result) {
|
||||||
return result.status === 'fulfilled';
|
return result.status === 'fulfilled' && (!result.value || result.value.ok !== false);
|
||||||
}).length;
|
}).length;
|
||||||
|
return successful;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Commands and synchronization actions forwarded from the web app to players.
|
||||||
|
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||||
|
|
||||||
@@ -215,6 +217,31 @@ function createPlayerActionService(options) {
|
|||||||
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
|
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) {
|
async function forwardAnnouncementRefresh(slug) {
|
||||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||||
const targetBaseUrls = Array.from(new Set([
|
const targetBaseUrls = Array.from(new Set([
|
||||||
@@ -367,6 +394,7 @@ function createPlayerActionService(options) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
forwardPlayerCommand: forwardPlayerCommand,
|
forwardPlayerCommand: forwardPlayerCommand,
|
||||||
|
resolvePlayerBaseUrlForPublicUrl: resolvePlayerBaseUrlForPublicUrl,
|
||||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||||
getScreenConnections: getScreenConnections,
|
getScreenConnections: getScreenConnections,
|
||||||
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Load and normalize browser region scripts for server-rendered player pages.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Start background services, scheduled tasks, and web application dependencies.
|
||||||
|
|
||||||
async function initializeWebServer(options) {
|
async function initializeWebServer(options) {
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
|
|||||||
@@ -969,8 +969,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||||
if (button) {
|
if (collapseButton) {
|
||||||
button.classList.add('d-none');
|
collapseButton.classList.add('d-none');
|
||||||
|
}
|
||||||
|
if (expandButton) {
|
||||||
|
expandButton.classList.add('d-none');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -979,8 +982,11 @@
|
|||||||
try {
|
try {
|
||||||
parsedJson = JSON.parse(rawJson);
|
parsedJson = JSON.parse(rawJson);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (button) {
|
if (collapseButton) {
|
||||||
button.classList.add('d-none');
|
collapseButton.classList.add('d-none');
|
||||||
|
}
|
||||||
|
if (expandButton) {
|
||||||
|
expandButton.classList.add('d-none');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
function getSelectedScreenClients(state) {
|
function getSelectedScreenClients(state) {
|
||||||
var select = getScreenCommandSelect();
|
var select = getScreenCommandSelect();
|
||||||
var selectedSlug = select ? String(select.value || '').trim() : '';
|
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__') {
|
if (!selectedSlug || selectedSlug === '__all__') {
|
||||||
return clients;
|
return clients;
|
||||||
@@ -336,15 +336,16 @@
|
|||||||
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
|
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
|
||||||
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
|
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
|
||||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||||
|
var commandTargetId = client.clientId || client.id || '';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<div class="actions justify-content-end">',
|
'<div class="actions justify-content-end">',
|
||||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
'<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(commandTargetId) + '" /><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" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
||||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(commandTargetId) + '" /><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="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(commandTargetId) + '" /><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="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(commandTargetId) + '" /><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>',
|
'<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(commandTargetId) + '" /><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>'
|
'</div>'
|
||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
@@ -368,7 +369,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
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 clientId = String(row.getAttribute('data-client-client-id') || '').trim();
|
||||||
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
|
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
|
||||||
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
||||||
@@ -428,7 +429,7 @@
|
|||||||
}
|
}
|
||||||
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
|
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
|
||||||
if (connectionInput) {
|
if (connectionInput) {
|
||||||
connectionInput.value = client.id || '';
|
connectionInput.value = client.clientId || client.id || '';
|
||||||
}
|
}
|
||||||
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
|
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
|
||||||
if (playerBaseUrlInput) {
|
if (playerBaseUrlInput) {
|
||||||
@@ -447,7 +448,7 @@
|
|||||||
if (reloadForm) {
|
if (reloadForm) {
|
||||||
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
|
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
|
||||||
if (reloadInput) {
|
if (reloadInput) {
|
||||||
reloadInput.value = client.id || '';
|
reloadInput.value = client.clientId || client.id || '';
|
||||||
}
|
}
|
||||||
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
|
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
|
||||||
if (reloadPlayerBaseUrlInput) {
|
if (reloadPlayerBaseUrlInput) {
|
||||||
@@ -483,7 +484,7 @@
|
|||||||
}
|
}
|
||||||
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
|
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
|
||||||
if (blackoutConnectionInput) {
|
if (blackoutConnectionInput) {
|
||||||
blackoutConnectionInput.value = client.id || '';
|
blackoutConnectionInput.value = client.clientId || client.id || '';
|
||||||
}
|
}
|
||||||
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
|
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
|
||||||
if (blackoutPlayerBaseUrlInput) {
|
if (blackoutPlayerBaseUrlInput) {
|
||||||
@@ -508,7 +509,7 @@
|
|||||||
}
|
}
|
||||||
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
|
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
|
||||||
if (previousConnectionInput) {
|
if (previousConnectionInput) {
|
||||||
previousConnectionInput.value = client.id || '';
|
previousConnectionInput.value = client.clientId || client.id || '';
|
||||||
}
|
}
|
||||||
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
|
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
|
||||||
if (previousPlayerBaseUrlInput) {
|
if (previousPlayerBaseUrlInput) {
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
}
|
}
|
||||||
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
|
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
|
||||||
if (nextConnectionInput) {
|
if (nextConnectionInput) {
|
||||||
nextConnectionInput.value = client.id || '';
|
nextConnectionInput.value = client.clientId || client.id || '';
|
||||||
}
|
}
|
||||||
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
|
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
|
||||||
if (nextPlayerBaseUrlInput) {
|
if (nextPlayerBaseUrlInput) {
|
||||||
|
|||||||
@@ -93,8 +93,22 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
}
|
}
|
||||||
|
|
||||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
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.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.backgroundPosition = 'center';
|
||||||
element.style.backgroundSize = 'cover';
|
element.style.backgroundSize = 'cover';
|
||||||
element.style.backgroundRepeat = 'no-repeat';
|
element.style.backgroundRepeat = 'no-repeat';
|
||||||
@@ -110,7 +124,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
var gradient = '';
|
var gradient = '';
|
||||||
try {
|
try {
|
||||||
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
|
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 }; });
|
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);
|
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) {
|
if (stops.length >= 2) {
|
||||||
|
|||||||
@@ -187,163 +187,8 @@
|
|||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.escapeHtml = escapeHtml;
|
|
||||||
initializeUrlValidation();
|
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 '<option value="' + escapeHtml(optionValue) + '"' + (String(optionValue) === String(currentValue) ? ' selected' : '') + '>' + escapeHtml(optionLabel) + '</option>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSvgMarkup(svg) {
|
|
||||||
var markup = String(svg || '').trim();
|
|
||||||
if (!markup || markup.charAt(0) !== '<') {
|
|
||||||
return markup;
|
|
||||||
}
|
|
||||||
|
|
||||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
|
||||||
var attrs = String(attrText || '');
|
|
||||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
|
||||||
attrs += ' width="95%"';
|
|
||||||
}
|
|
||||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
|
||||||
attrs += ' height="95%"';
|
|
||||||
}
|
|
||||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
|
||||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
|
||||||
}
|
|
||||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
|
||||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
|
||||||
}
|
|
||||||
return '<svg' + attrs + '>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeQrSvg(svg) {
|
|
||||||
return normalizeSvgMarkup(svg);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blobToText(blob) {
|
async function blobToText(blob) {
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
return '';
|
return '';
|
||||||
@@ -369,23 +214,8 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.defaultQrOptions = defaultQrOptions;
|
|
||||||
window.qrDotTypes = qrDotTypes;
|
|
||||||
window.qrCornerSquareTypes = qrCornerSquareTypes;
|
|
||||||
window.qrCornerDotTypes = qrCornerDotTypes;
|
|
||||||
window.normalizeMarginValue = normalizeMarginValue;
|
|
||||||
window.normalizeHexColorValue = normalizeHexColorValue;
|
|
||||||
window.normalizeBooleanValue = normalizeBooleanValue;
|
|
||||||
window.normalizeOptionValue = normalizeOptionValue;
|
|
||||||
window.normalizeRatioValue = normalizeRatioValue;
|
|
||||||
window.normalizeGradientTypeValue = normalizeGradientTypeValue;
|
|
||||||
window.normalizeGradientRotationValue = normalizeGradientRotationValue;
|
|
||||||
window.normalizeColorModeValue = normalizeColorModeValue;
|
|
||||||
window.normalizeColorValue = normalizeColorValue;
|
|
||||||
window.renderSelectOptions = renderSelectOptions;
|
|
||||||
window.normalizeSvgMarkup = normalizeSvgMarkup;
|
|
||||||
window.normalizeQrSvg = normalizeQrSvg;
|
|
||||||
window.blobToText = blobToText;
|
window.blobToText = blobToText;
|
||||||
|
window.escapeHtml = escapeHtml;
|
||||||
window.pendingByRegionId = window.pendingByRegionId || Object.create(null);
|
window.pendingByRegionId = window.pendingByRegionId || Object.create(null);
|
||||||
|
|
||||||
window.webUiHelpers = {
|
window.webUiHelpers = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
||||||
const getScreenConnections = deps.getScreenConnections;
|
const getScreenConnections = deps.getScreenConnections;
|
||||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||||
|
const findAvailableClientName = deps.findAvailableClientName;
|
||||||
const withClientNameReservation = deps.withClientNameReservation;
|
const withClientNameReservation = deps.withClientNameReservation;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
@@ -297,8 +298,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
liveConnections = [];
|
liveConnections = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections);
|
const selectedClientName = await findAvailableClientName(pool, clientName, bindingDeviceId, liveConnections);
|
||||||
if (!available) {
|
if (!selectedClientName) {
|
||||||
return res.status(409).json({ error: 'Client name already exists.' });
|
return res.status(409).json({ error: 'Client name already exists.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,14 +307,14 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
if (playerBaseUrl) {
|
if (playerBaseUrl) {
|
||||||
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
||||||
command: command,
|
command: command,
|
||||||
clientName: clientName,
|
clientName: selectedClientName,
|
||||||
clientId: connectionId || deviceId || null,
|
clientId: connectionId || deviceId || null,
|
||||||
deviceId: deviceId || null
|
deviceId: deviceId || null
|
||||||
}, connectionId || deviceId || undefined, deviceId || null);
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
} else {
|
} else {
|
||||||
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
||||||
command: command,
|
command: command,
|
||||||
clientName: clientName,
|
clientName: selectedClientName,
|
||||||
clientId: connectionId || deviceId || null,
|
clientId: connectionId || deviceId || null,
|
||||||
deviceId: deviceId || null
|
deviceId: deviceId || null
|
||||||
}, connectionId || deviceId || undefined, deviceId || null);
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
@@ -329,7 +330,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
command: command,
|
command: command,
|
||||||
connectionId: connectionId || null,
|
connectionId: connectionId || null,
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
clientName: clientName,
|
clientName: selectedClientName,
|
||||||
ok: true,
|
ok: true,
|
||||||
liveOnly: true
|
liveOnly: true
|
||||||
});
|
});
|
||||||
@@ -339,7 +340,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
`UPDATE d_onboarding_devices
|
`UPDATE d_onboarding_devices
|
||||||
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
|
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
|
||||||
WHERE device_id = ?`,
|
WHERE device_id = ?`,
|
||||||
[clientName, bindingDeviceId]
|
[selectedClientName, bindingDeviceId]
|
||||||
);
|
);
|
||||||
if (!updateResult.affectedRows) {
|
if (!updateResult.affectedRows) {
|
||||||
return res.status(404).json({ error: 'Client not found' });
|
return res.status(404).json({ error: 'Client not found' });
|
||||||
@@ -351,7 +352,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
command: command,
|
command: command,
|
||||||
connectionId: connectionId || null,
|
connectionId: connectionId || null,
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
clientName: clientName,
|
clientName: selectedClientName,
|
||||||
ok: true
|
ok: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -360,35 +361,60 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
if (command === 'moveclient') {
|
if (command === 'moveclient') {
|
||||||
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
|
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
|
||||||
let physicalPlayerId = '';
|
let physicalPlayerId = '';
|
||||||
|
let registeredPlayerBaseUrl = '';
|
||||||
const tabClientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
|
const tabClientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
|
||||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||||
const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
||||||
|
const liveConnections = await fetchScreenConnections(slug);
|
||||||
|
const liveConnection = liveConnections.find(function (connection) {
|
||||||
|
const candidateConnectionId = String(connection && connection.id || '').trim();
|
||||||
|
const candidateClientId = String(connection && connection.clientId || '').trim();
|
||||||
|
return candidateConnectionId === connectionId || candidateClientId === connectionId;
|
||||||
|
}) || null;
|
||||||
|
const liveDeviceId = String(liveConnection && liveConnection.deviceId || '').trim();
|
||||||
|
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
|
||||||
|
|
||||||
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId) {
|
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId && !liveDeviceId) {
|
||||||
return res.status(400).json({ error: 'Client connection is required' });
|
return res.status(400).json({ error: 'Client connection is required' });
|
||||||
}
|
}
|
||||||
if (!targetScreenSlug) {
|
if (!targetScreenSlug) {
|
||||||
return res.status(400).json({ error: 'Target screen is required' });
|
return res.status(400).json({ error: 'Target screen is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submittedPlayerBaseUrl) {
|
if (livePlayerBaseUrl || submittedPlayerBaseUrl) {
|
||||||
const [playerRows] = await pool.query(
|
const [playerRows] = await pool.query(
|
||||||
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
|
'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1',
|
||||||
[submittedPlayerBaseUrl]
|
[livePlayerBaseUrl || submittedPlayerBaseUrl]
|
||||||
);
|
);
|
||||||
const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim();
|
const registeredPlayer = playerRows[0] || null;
|
||||||
if (registeredPlayerId) {
|
physicalPlayerId = String(registeredPlayer && registeredPlayer.identifier || '').trim();
|
||||||
physicalPlayerId = registeredPlayerId;
|
registeredPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(registeredPlayer && registeredPlayer.public_base_url);
|
||||||
|
} else if (legacyDeviceId || liveDeviceId) {
|
||||||
|
const [playerRows] = await pool.query(
|
||||||
|
'SELECT identifier, public_base_url FROM d_players WHERE identifier = ? LIMIT 1',
|
||||||
|
[legacyDeviceId || liveDeviceId]
|
||||||
|
);
|
||||||
|
const registeredPlayer = playerRows[0] || null;
|
||||||
|
physicalPlayerId = String(registeredPlayer && registeredPlayer.identifier || '').trim();
|
||||||
|
registeredPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(registeredPlayer && registeredPlayer.public_base_url);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
physicalPlayerId = physicalPlayerId || legacyDeviceId;
|
|
||||||
if (!physicalPlayerId) {
|
if (!physicalPlayerId) {
|
||||||
return res.status(400).json({ error: 'Registered player identity is required' });
|
return res.status(400).json({ error: 'Registered player identity is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentRows] = await pool.query(
|
let [currentRows] = tabClientId
|
||||||
|
? await pool.query(
|
||||||
|
`SELECT d.client_name, s.slug AS current_screen_slug
|
||||||
|
FROM d_onboarding_devices d
|
||||||
|
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||||
|
WHERE d.device_id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[tabClientId]
|
||||||
|
)
|
||||||
|
: [[]];
|
||||||
|
if (!currentRows.length && physicalPlayerId && physicalPlayerId !== tabClientId) {
|
||||||
|
[currentRows] = await pool.query(
|
||||||
`SELECT d.client_name, s.slug AS current_screen_slug
|
`SELECT d.client_name, s.slug AS current_screen_slug
|
||||||
FROM d_onboarding_devices d
|
FROM d_onboarding_devices d
|
||||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||||
@@ -396,6 +422,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[physicalPlayerId]
|
[physicalPlayerId]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const onboardingRow = currentRows[0] || null;
|
const onboardingRow = currentRows[0] || null;
|
||||||
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
||||||
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
|
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
|
||||||
@@ -418,7 +445,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveConnections = await fetchScreenConnections(slug);
|
|
||||||
let status = null;
|
let status = null;
|
||||||
if (onboardingRow) {
|
if (onboardingRow) {
|
||||||
const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]);
|
const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]);
|
||||||
@@ -485,14 +511,20 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let targetPlayerUrl = '';
|
let targetPlayerUrl = '';
|
||||||
const liveConnection = Array.isArray(liveConnections)
|
const matchedLiveConnection = Array.isArray(liveConnections)
|
||||||
? liveConnections.find(function (connection) {
|
? liveConnections.find(function (connection) {
|
||||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
const candidateConnectionId = String(connection && connection.id || '').trim();
|
||||||
|
const candidateClientId = String(connection && connection.clientId || '').trim();
|
||||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||||
return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId;
|
return candidateConnectionId === connectionId
|
||||||
|
|| candidateClientId === connectionId
|
||||||
|
|| candidateConnectionId === physicalPlayerId
|
||||||
|
|| candidateClientId === physicalPlayerId
|
||||||
|
|| candidateDeviceId === physicalPlayerId;
|
||||||
}) || liveConnections[0] || null
|
}) || liveConnections[0] || null
|
||||||
: null;
|
: null;
|
||||||
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
|
const sourcePlayerBaseUrl = registeredPlayerBaseUrl
|
||||||
|
|| normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl);
|
||||||
if (sourcePlayerBaseUrl) {
|
if (sourcePlayerBaseUrl) {
|
||||||
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
|
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||||
}
|
}
|
||||||
@@ -505,8 +537,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
connectionId: connectionId || null,
|
connectionId: connectionId || null,
|
||||||
screenSlug: targetScreenSlug
|
screenSlug: targetScreenSlug
|
||||||
});
|
});
|
||||||
if (playerBaseUrl) {
|
if (sourcePlayerBaseUrl) {
|
||||||
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
await forwardPlayerCommandForPlayerBaseUrl(slug, sourcePlayerBaseUrl, {
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: targetPlayerUrl,
|
url: targetPlayerUrl,
|
||||||
moveToken: moveToken || null
|
moveToken: moveToken || null
|
||||||
@@ -544,8 +576,17 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const liveConnections = await fetchScreenConnections(slug);
|
const liveConnections = await fetchScreenConnections(slug);
|
||||||
const result = playerBaseUrl
|
const liveConnection = connectionId
|
||||||
? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
|
? liveConnections.find(function (connection) {
|
||||||
|
const candidateConnectionId = String(connection && connection.id || '').trim();
|
||||||
|
const candidateClientId = String(connection && connection.clientId || '').trim();
|
||||||
|
return candidateConnectionId === connectionId || candidateClientId === connectionId;
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
|
||||||
|
const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl;
|
||||||
|
const result = targetPlayerBaseUrl
|
||||||
|
? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null)
|
||||||
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
|
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
|
||||||
|
|
||||||
if (typeof broadcastDashboardState === 'function') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Web routes for pairing players and completing onboarding device bindings.
|
||||||
|
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||||
const { renderView } = require('../view');
|
const { renderView } = require('../view');
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ function registerSignageRoutes(app, deps) {
|
|||||||
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
||||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||||
isClientNameAvailable: deps.isClientNameAvailable,
|
isClientNameAvailable: deps.isClientNameAvailable,
|
||||||
|
findAvailableClientName: deps.findAvailableClientName,
|
||||||
withClientNameReservation: deps.withClientNameReservation,
|
withClientNameReservation: deps.withClientNameReservation,
|
||||||
broadcastDashboardState: deps.broadcastDashboardState,
|
broadcastDashboardState: deps.broadcastDashboardState,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{#if weatherPreview.hasSnapshot}}
|
{{#if weatherPreview.hasSnapshot}}
|
||||||
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
|
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
|
||||||
<div id="weather-hourly-forecast"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
<div id="weather-hourly-forecast" class="d-none"><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="small text-body-secondary">Daily and hourly forecasts will appear here after the first successful fetch.</div>
|
<div class="small text-body-secondary">Daily and hourly forecasts will appear here after the first successful fetch.</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
var backgroundGradient = '';
|
var backgroundGradient = '';
|
||||||
try {
|
try {
|
||||||
var gradientData = typeof payload.backgroundGradient === 'string' ? JSON.parse(payload.backgroundGradient) : payload.backgroundGradient;
|
var gradientData = typeof payload.backgroundGradient === 'string' ? JSON.parse(payload.backgroundGradient) : payload.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 gradientStops = 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 }; });
|
var gradientStops = 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 }; });
|
||||||
gradientStops = gradientStops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
gradientStops = gradientStops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
||||||
if (gradientStops.length >= 2) {
|
if (gradientStops.length >= 2) {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ test('move client rebinding redirects the live player to the target screen', asy
|
|||||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-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' }]];
|
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ?')) {
|
||||||
|
return [[{ identifier: 'player-a', public_base_url: 'http://remote-player.example' }]];
|
||||||
|
}
|
||||||
if (sql.includes('INSERT INTO d_onboarding_devices')) {
|
if (sql.includes('INSERT INTO d_onboarding_devices')) {
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,61 @@ test('move client rebinding redirects the live player to the target screen', asy
|
|||||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('move client requires a registered player identity', async () => {
|
||||||
|
const { app, handlers } = createHandlers();
|
||||||
|
|
||||||
|
registerScreenCommandRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
|
||||||
|
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: { fetchPlayerRegistrations: async () => [] },
|
||||||
|
forwardPlayerCommand: async () => ({ ok: true }),
|
||||||
|
forwardPlayerCommandToBaseUrl: async () => ({ ok: true }),
|
||||||
|
getScreenConnections: async () => ({ connections: [] }),
|
||||||
|
isClientNameAvailable: async () => true,
|
||||||
|
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||||
|
broadcastDashboardState: async () => {},
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value) {
|
||||||
|
this.body = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await handlers['/clients/:slug/commands'][1]({
|
||||||
|
params: { slug: 'source-screen' },
|
||||||
|
body: {
|
||||||
|
command: 'moveclient',
|
||||||
|
deviceId: 'unregistered-device',
|
||||||
|
clientName: 'Lobby Client',
|
||||||
|
targetScreenSlug: 'target-screen',
|
||||||
|
playerBaseUrl: 'http://unregistered-player.example'
|
||||||
|
},
|
||||||
|
query: {}
|
||||||
|
}, response, () => {});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.body.error, 'Registered player identity is required');
|
||||||
|
});
|
||||||
|
|
||||||
test('screen control commands can target all screens', async () => {
|
test('screen control commands can target all screens', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const { app, handlers } = createHandlers();
|
const { app, handlers } = createHandlers();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
collectLiveConnections,
|
collectLiveConnections,
|
||||||
|
findAvailableClientName,
|
||||||
isClientNameAvailable,
|
isClientNameAvailable,
|
||||||
normalizeClientName,
|
normalizeClientName,
|
||||||
normalizeDeviceId,
|
normalizeDeviceId,
|
||||||
@@ -22,7 +23,7 @@ test('collectLiveConnections returns an array safely', () => {
|
|||||||
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
|
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isClientNameAvailable rejects matching db rows and live connections', async () => {
|
test('isClientNameAvailable ignores stored names for offline devices', async () => {
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
assert.match(sql, /FROM d_onboarding_devices/);
|
assert.match(sql, /FROM d_onboarding_devices/);
|
||||||
@@ -31,12 +32,28 @@ test('isClientNameAvailable rejects matching db rows and live connections', asyn
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
|
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), true);
|
||||||
|
assert.equal(await isClientNameAvailable(pool, 'Screen A', 'device-01', [{ clientName: 'screen a', deviceId: 'device-99' }]), false);
|
||||||
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
|
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
|
||||||
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
|
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
|
||||||
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
|
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('findAvailableClientName adds the first free numeric suffix', async () => {
|
||||||
|
const occupiedNames = new Set(['Lobby', 'Lobby (1)']);
|
||||||
|
const pool = {
|
||||||
|
async query(_sql, params) {
|
||||||
|
const name = params[0];
|
||||||
|
return [[occupiedNames.has(name) ? { device_id: 'active-device' } : undefined].filter(Boolean)];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findAvailableClientName(pool, 'Lobby', 'new-device', [{ deviceId: 'active-device' }]),
|
||||||
|
'Lobby (2)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('withClientNameReservation acquires and releases locks around the handler', async () => {
|
test('withClientNameReservation acquires and releases locks around the handler', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
|
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
|
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../src/web/lib/data-source-refresh');
|
||||||
|
|
||||||
function createConnection(options) {
|
function createConnection(options) {
|
||||||
const state = Object.assign({
|
const state = Object.assign({
|
||||||
@@ -178,3 +178,86 @@ test('refreshRssFeed skips notifications when the RSS items are unchanged', asyn
|
|||||||
|
|
||||||
assert.deepEqual(notifyCalls, []);
|
assert.deepEqual(notifyCalls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshWeatherLocation notifies players when the forecast changes', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-c' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = { async getConnection() { return connection; } };
|
||||||
|
const common = {
|
||||||
|
async fetchWeatherLocationForecast() {
|
||||||
|
return { responseJson: JSON.stringify({ temperature: 21 }) };
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshWeatherLocation(pool, common, {
|
||||||
|
id: 4,
|
||||||
|
last_pulled_at: new Date(),
|
||||||
|
last_response_json: JSON.stringify({ temperature: 20 })
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshWeatherLocation skips notifications when the forecast is unchanged within the hour', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-c' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = { async getConnection() { return connection; } };
|
||||||
|
const snapshot = JSON.stringify({ temperature: 20 });
|
||||||
|
const common = {
|
||||||
|
async fetchWeatherLocationForecast() {
|
||||||
|
return { responseJson: snapshot };
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshWeatherLocation(pool, common, {
|
||||||
|
id: 4,
|
||||||
|
last_pulled_at: new Date(),
|
||||||
|
last_response_json: snapshot
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshWeatherLocation notifies when an unchanged forecast crosses into a new hour', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-c' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = { async getConnection() { return connection; } };
|
||||||
|
const snapshot = JSON.stringify({ temperature: 20 });
|
||||||
|
const common = {
|
||||||
|
async fetchWeatherLocationForecast() {
|
||||||
|
return { responseJson: snapshot };
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshWeatherLocation(pool, common, {
|
||||||
|
id: 4,
|
||||||
|
last_pulled_at: new Date('2000-01-01T00:00:00.000Z'),
|
||||||
|
last_response_json: snapshot
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createNotifyPlayerScreens } = require('../src/web/lib/notify-player-screens');
|
||||||
|
|
||||||
|
test('playlist notifications use the player URL reported by the screen connection', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const notify = createNotifyPlayerScreens(
|
||||||
|
async function () {
|
||||||
|
calls.push({ type: 'fallback' });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async function () {
|
||||||
|
return {
|
||||||
|
connections: [{ playerPublicBaseUrl: 'http://192.168.0.80:8082' }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async function (baseUrl, slug, command) {
|
||||||
|
calls.push({ baseUrl, slug, command });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async function () {
|
||||||
|
return 'http://player:8081';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = await notify(['test'], 'refresh');
|
||||||
|
|
||||||
|
assert.equal(count, 1);
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
baseUrl: 'http://player:8081',
|
||||||
|
slug: 'test',
|
||||||
|
command: 'refresh'
|
||||||
|
}]);
|
||||||
|
});
|
||||||
@@ -14,7 +14,27 @@ test('prunes old unbound onboarding devices without deleting completed bindings'
|
|||||||
|
|
||||||
await pruneStaleOnboardingDevices(pool);
|
await pruneStaleOnboardingDevices(pool);
|
||||||
|
|
||||||
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 1 MINUTE\)/);
|
assert.match(queryText, /screen_id IS NULL/);
|
||||||
assert.match(queryText, /WHERE screen_id IS NULL/);
|
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 15 MINUTE\)/);
|
||||||
|
assert.match(queryText, /last_seen_at < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
|
||||||
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
|
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updates an onboarding device last-seen timestamp by device id', async () => {
|
||||||
|
let queryText = '';
|
||||||
|
let queryParams = null;
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params) {
|
||||||
|
queryText = sql;
|
||||||
|
queryParams = params;
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { touchOnboardingDeviceLastSeen } = require('../src/db/common');
|
||||||
|
await touchOnboardingDeviceLastSeen(pool, ['device-123', 'client-123']);
|
||||||
|
|
||||||
|
assert.match(queryText, /SET last_seen_at = CURRENT_TIMESTAMP/);
|
||||||
|
assert.match(queryText, /WHERE device_id IN \(\?, \?\)/);
|
||||||
|
assert.deepEqual(queryParams, ['device-123', 'client-123']);
|
||||||
|
});
|
||||||
@@ -96,6 +96,52 @@ test('player runtime snapshots websocket state and checks live names', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('player runtime suffixes a reconnecting client name already in use', async () => {
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
|
const persistedNames = [];
|
||||||
|
const runtime = createPlayerRuntime({
|
||||||
|
pool: {
|
||||||
|
async query(_sql, params) {
|
||||||
|
return [[params[0] === 'Lobby' && params[1] !== 'device-a' ? { device_id: 'device-a' } : undefined].filter(Boolean)];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
persistClientName(deviceId, clientName) {
|
||||||
|
persistedNames.push({ deviceId, clientName });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const server = http.createServer();
|
||||||
|
runtime.installWebsocket(server);
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
const token = createPageAuthToken({ scope: 'player', slug: 'reconnect-test' });
|
||||||
|
const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`);
|
||||||
|
const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
new Promise((resolve, reject) => { clientA.once('open', resolve); clientA.once('error', reject); }),
|
||||||
|
new Promise((resolve, reject) => { clientB.once('open', resolve); clientB.once('error', reject); })
|
||||||
|
]);
|
||||||
|
clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Lobby', deviceId: 'device-a' }));
|
||||||
|
await waitFor(() => runtime.snapshotConnections('reconnect-test').some((connection) => connection.clientName === 'Lobby'));
|
||||||
|
clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Lobby', deviceId: 'device-b' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const connections = runtime.snapshotConnections('reconnect-test');
|
||||||
|
return connections.length === 2 && connections.some((connection) => connection.clientName === 'Lobby (1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(persistedNames, [{ deviceId: 'device-b', clientName: 'Lobby (1)' }]);
|
||||||
|
} finally {
|
||||||
|
clientA.close();
|
||||||
|
clientB.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('player runtime accepts websocket auth from cookies', async () => {
|
test('player runtime accepts websocket auth from cookies', async () => {
|
||||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
@@ -199,3 +245,29 @@ test('player runtime sends targeted and broadcast commands to live sockets', asy
|
|||||||
await new Promise((resolve) => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('player runtime removes browser connections that stop sending state', async () => {
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
|
const runtime = createPlayerRuntime({ pool: null, staleConnectionMs: 50 });
|
||||||
|
const server = http.createServer();
|
||||||
|
runtime.installWebsocket(server);
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
const token = createPageAuthToken({ scope: 'player', slug: 'stale-test' });
|
||||||
|
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/stale-test?auth=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
client.once('open', resolve);
|
||||||
|
client.once('error', reject);
|
||||||
|
});
|
||||||
|
client.send(JSON.stringify({ type: 'state', clientId: 'stale-client', clientName: 'Stale Player' }));
|
||||||
|
await waitFor(() => runtime.snapshotConnections('stale-test').length === 1);
|
||||||
|
await waitFor(() => runtime.snapshotConnections('stale-test').length === 0, 1000);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.1' });
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.2' });
|
||||||
|
|
||||||
assert.equal(pendingMigrations.length, 0);
|
assert.equal(pendingMigrations.length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,5 +35,8 @@ test('weather forecast preview only shows the fetch hint without a snapshot', ()
|
|||||||
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8');
|
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8');
|
||||||
|
|
||||||
assert.match(template, /\{\{#if weatherPreview\.hasSnapshot\}\}[\s\S]*weather-daily-forecast[\s\S]*weather-hourly-forecast[\s\S]*\{\{\/if\}\}/);
|
assert.match(template, /\{\{#if weatherPreview\.hasSnapshot\}\}[\s\S]*weather-daily-forecast[\s\S]*weather-hourly-forecast[\s\S]*\{\{\/if\}\}/);
|
||||||
|
assert.match(template, /id="weather-daily-forecast" class="weather-daily-forecast"/);
|
||||||
|
assert.match(template, /id="weather-hourly-forecast" class="d-none"/);
|
||||||
|
assert.doesNotMatch(template, /Hourly forecast · next 24 hours/);
|
||||||
assert.match(template, /Daily and hourly forecasts will appear here after the first successful fetch\./);
|
assert.match(template, /Daily and hourly forecasts will appear here after the first successful fetch\./);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user