Release v2.10.3
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m13s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
2026-08-29 01:56:45 +01:00
parent 30814f3f46
commit 3f4f57020a
60 changed files with 1075 additions and 523 deletions
@@ -68,7 +68,7 @@ function registerRecurringDataSourceRefreshes(options) {
category: TASK.category,
intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit),
metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name },
run: function () { return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); }
run: function () { return refreshWeatherLocation(pool, common, location.id, null, options.notifyPlayerScreens); }
});
});
})();
+23 -2
View File
@@ -1,3 +1,5 @@
// Refresh data sources and notify only the screens whose rendered data changed.
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
const slideIds = [];
@@ -106,6 +108,24 @@ function hasApiSourceChanged(apiSource, responseDetails) {
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
}
function isDifferentHour(previousPulledAt, currentPulledAt) {
const previous = new Date(previousPulledAt);
const current = new Date(currentPulledAt);
if (!Number.isFinite(previous.getTime())) {
return false;
}
return previous.getFullYear() !== current.getFullYear()
|| previous.getMonth() !== current.getMonth()
|| previous.getDate() !== current.getDate()
|| previous.getHours() !== current.getHours();
}
function hasWeatherLocationChanged(location, responseDetails, refreshedAt) {
return normalizeSnapshotValue(location && location.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson)
|| isDifferentHour(location && location.last_pulled_at, refreshedAt);
}
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection();
try {
@@ -169,16 +189,17 @@ async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId
pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.');
}
const refreshedAt = new Date();
await connection.beginTransaction();
await connection.query(
'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?',
[new Date(), pullError || null, result ? result.responseJson : null, actorId, location.id]
[refreshedAt, pullError || null, result ? result.responseJson : null, actorId, location.id]
);
await connection.commit();
if (pullError) {
console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError);
} else if (typeof notifyAffectedScreens === 'function') {
} else if (hasWeatherLocationChanged(location, result, refreshedAt) && typeof notifyPlayerScreens === 'function') {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id);
} catch (notifyError) {
+20 -7
View File
@@ -389,6 +389,9 @@ function createUploadSyncService(options) {
}
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
if (String(uploadPath || '').startsWith('/media/player-cache/')) {
continue;
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -432,10 +435,6 @@ function createUploadSyncService(options) {
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
if (!relativeDir && entryName === 'player-cache') {
continue;
}
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
@@ -445,7 +444,11 @@ function createUploadSyncService(options) {
continue;
}
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
const normalizedRelativePath = nextRelativePath.replace(/\\/g, '/');
if (normalizedRelativePath.startsWith('player-cache/') && !normalizedRelativePath.startsWith('player-cache/remote-images/')) {
continue;
}
uploadPaths.push((normalizedRelativePath.startsWith('player-cache/') ? '/media/' : '/media/uploads/') + normalizedRelativePath);
}
}
@@ -930,6 +933,12 @@ function createUploadSyncService(options) {
});
});
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir);
const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}).forEach(function (uploadPath) {
uploadRefs.add(uploadPath);
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
@@ -957,8 +966,12 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir);
const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}));
if (nextUploadRefs.length) {
await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
}
if (operation.previousUploadRefs.length) {
+23 -4
View File
@@ -1,4 +1,6 @@
function createNotifyPlayerScreens(forwardPlayerCommand) {
// Route screen refresh commands to the physical player hosting each screen.
function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) {
if (typeof forwardPlayerCommand !== 'function') {
throw new Error('createNotifyPlayerScreens requires a player command sender.');
}
@@ -12,12 +14,29 @@ function createNotifyPlayerScreens(forwardPlayerCommand) {
return Promise.resolve(0);
}
return Promise.allSettled(uniqueSlugs.map(function (slug) {
return Promise.allSettled(uniqueSlugs.map(async function (slug) {
// A screen may have connections on multiple players after a remote move or reconnect.
if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') {
const screenState = await getScreenConnections(slug);
const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : [];
const playerBaseUrls = Array.from(new Set(connections.map(function (connection) {
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
if (playerBaseUrls.length) {
const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); }))
: playerBaseUrls;
return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh');
}));
}
}
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) {
return results.filter(function (result) {
return result.status === 'fulfilled';
const successful = results.filter(function (result) {
return result.status === 'fulfilled' && (!result.value || result.value.ok !== false);
}).length;
return successful;
});
};
}
+28
View File
@@ -1,3 +1,5 @@
// Commands and synchronization actions forwarded from the web app to players.
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
@@ -215,6 +217,31 @@ function createPlayerActionService(options) {
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
}
async function resolvePlayerBaseUrlForPublicUrl(playerPublicBaseUrl) {
const normalizedPublicBaseUrl = normalizeBaseUrl(playerPublicBaseUrl);
if (!normalizedPublicBaseUrl) {
return '';
}
try {
const players = await fetchPlayerRegistrations(pool);
const matchedPlayer = (Array.isArray(players) ? players : []).find(function (player) {
return normalizeBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
});
const registeredInternalBaseUrl = normalizeBaseUrl(matchedPlayer && matchedPlayer.internal_base_url);
if (registeredInternalBaseUrl) {
return registeredInternalBaseUrl;
}
} catch (_error) {
}
if (isLocalLikeBaseUrl(normalizedPublicBaseUrl) && configuredPlayerInternalBaseUrl) {
return configuredPlayerInternalBaseUrl;
}
return normalizedPublicBaseUrl;
}
async function forwardAnnouncementRefresh(slug) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([
@@ -367,6 +394,7 @@ function createPlayerActionService(options) {
return {
forwardPlayerCommand: forwardPlayerCommand,
resolvePlayerBaseUrlForPublicUrl: resolvePlayerBaseUrlForPublicUrl,
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
+2
View File
@@ -1,3 +1,5 @@
// Load and normalize browser region scripts for server-rendered player pages.
const fs = require('fs');
const path = require('path');
+2
View File
@@ -1,3 +1,5 @@
// Start background services, scheduled tasks, and web application dependencies.
async function initializeWebServer(options) {
const common = options && options.common;
const pool = options && options.pool;
+10 -4
View File
@@ -969,8 +969,11 @@
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
if (collapseButton) {
collapseButton.classList.add('d-none');
}
if (expandButton) {
expandButton.classList.add('d-none');
}
return;
}
@@ -979,8 +982,11 @@
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
if (collapseButton) {
collapseButton.classList.add('d-none');
}
if (expandButton) {
expandButton.classList.add('d-none');
}
return;
}
+13 -12
View File
@@ -35,7 +35,7 @@
function getSelectedScreenClients(state) {
var select = getScreenCommandSelect();
var selectedSlug = select ? String(select.value || '').trim() : '';
var clients = Array.isArray(state && state.clients) ? state.clients : [];
var clients = state && Array.isArray(state.clients) ? state.clients : [];
if (!selectedSlug || selectedSlug === '__all__') {
return clients;
@@ -336,15 +336,16 @@
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var commandTargetId = client.clientId || client.id || '';
return [
'<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>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
'<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(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(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(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>'
].join('');
}
@@ -368,7 +369,7 @@
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var connectionId = String(row.getAttribute('data-client-client-id') || row.getAttribute('data-client-id') || '').trim();
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
@@ -428,7 +429,7 @@
}
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
if (connectionInput) {
connectionInput.value = client.id || '';
connectionInput.value = client.clientId || client.id || '';
}
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
if (playerBaseUrlInput) {
@@ -447,7 +448,7 @@
if (reloadForm) {
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
if (reloadInput) {
reloadInput.value = client.id || '';
reloadInput.value = client.clientId || client.id || '';
}
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
if (reloadPlayerBaseUrlInput) {
@@ -483,7 +484,7 @@
}
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
if (blackoutConnectionInput) {
blackoutConnectionInput.value = client.id || '';
blackoutConnectionInput.value = client.clientId || client.id || '';
}
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
if (blackoutPlayerBaseUrlInput) {
@@ -508,7 +509,7 @@
}
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
if (previousConnectionInput) {
previousConnectionInput.value = client.id || '';
previousConnectionInput.value = client.clientId || client.id || '';
}
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
if (previousPlayerBaseUrlInput) {
@@ -533,7 +534,7 @@
}
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
if (nextConnectionInput) {
nextConnectionInput.value = client.id || '';
nextConnectionInput.value = client.clientId || client.id || '';
}
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
if (nextPlayerBaseUrlInput) {
+16 -2
View File
@@ -93,8 +93,22 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
}
var color = String(backgroundColor || '#111111').trim() || '#111111';
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (stopColor, index, colors) { return { color: stopColor, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
var angle = Number(gradientData.angle);
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
}
}
} catch (_error) {
gradient = '';
}
element.style.backgroundColor = color;
element.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : 'none';
element.style.backgroundImage = [backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : '', gradient].filter(Boolean).join(',') || 'none';
element.style.backgroundPosition = 'center';
element.style.backgroundSize = 'cover';
element.style.backgroundRepeat = 'no-repeat';
@@ -110,7 +124,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
+1 -171
View File
@@ -187,163 +187,8 @@
return ip;
}
window.escapeHtml = escapeHtml;
initializeUrlValidation();
var defaultQrOptions = window.defaultQrOptions || {
width: 1000,
height: 1000,
type: 'canvas',
margin: 10,
qrOptions: {},
dotsOptions: {
color: '#000000',
type: 'square'
},
cornersSquareOptions: {
color: '#000000',
type: 'square'
},
cornersDotOptions: {
color: '#000000',
type: 'square'
},
backgroundOptions: {
color: '#ffffff'
}
};
var qrDotTypes = window.qrDotTypes || [
{ value: 'square', label: 'Square' },
{ value: 'dots', label: 'Dots' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'extra-rounded', label: 'Extra rounded' },
{ value: 'classy', label: 'Classy' },
{ value: 'classy-rounded', label: 'Classy rounded' }
];
var qrCornerSquareTypes = window.qrCornerSquareTypes || [
{ value: 'square', label: 'Square' },
{ value: 'dot', label: 'Dot' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'extra-rounded', label: 'Extra rounded' },
{ value: 'dots', label: 'Dots' },
{ value: 'classy', label: 'Classy' },
{ value: 'classy-rounded', label: 'Classy rounded' }
];
var qrCornerDotTypes = window.qrCornerDotTypes || [
{ value: 'square', label: 'Square' },
{ value: 'dot', label: 'Dot' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'extra-rounded', label: 'Extra rounded' },
{ value: 'classy', label: 'Classy' },
{ value: 'classy-rounded', label: 'Classy rounded' }
];
function getOptionValue(option) {
if (option && typeof option === 'object' && option.value !== undefined && option.value !== null) {
return String(option.value).trim();
}
return String(option === undefined || option === null ? '' : option).trim();
}
function normalizeMarginValue(value, fallback) {
var parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback;
}
function normalizeHexColorValue(value, fallback) {
var raw = String(value === undefined || value === null ? '' : value).trim();
return raw || fallback;
}
function normalizeBooleanValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return Boolean(fallback);
}
if (typeof value === 'boolean') {
return value;
}
var text = String(value).trim().toLowerCase();
if (text === 'true' || text === '1' || text === 'yes' || text === 'on') {
return true;
}
if (text === 'false' || text === '0' || text === 'no' || text === 'off') {
return false;
}
return Boolean(value);
}
function normalizeOptionValue(value, options, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
for (var index = 0; index < options.length; index += 1) {
if (getOptionValue(options[index]) === text) {
return text;
}
}
return fallback;
}
function normalizeRatioValue(value, fallback) {
var parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function normalizeGradientTypeValue(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
return text === 'radial' ? 'radial' : 'linear';
}
function normalizeGradientRotationValue(value, fallback) {
var parsed = Number(value);
return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
}
function normalizeColorModeValue(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
return text === 'gradient' ? 'gradient' : 'single';
}
function normalizeColorValue(value, fallback) {
return normalizeHexColorValue(value, fallback);
}
function renderSelectOptions(options, currentValue) {
return options.map(function (option) {
var optionValue = getOptionValue(option);
var optionLabel = option && typeof option === 'object' && option.label !== undefined ? option.label : optionValue;
return '<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) {
if (!blob) {
return '';
@@ -369,23 +214,8 @@
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.escapeHtml = escapeHtml;
window.pendingByRegionId = window.pendingByRegionId || Object.create(null);
window.webUiHelpers = {
+75 -34
View File
@@ -9,6 +9,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const findAvailableClientName = deps.findAvailableClientName;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission;
@@ -297,8 +298,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
liveConnections = [];
}
const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections);
if (!available) {
const selectedClientName = await findAvailableClientName(pool, clientName, bindingDeviceId, liveConnections);
if (!selectedClientName) {
return res.status(409).json({ error: 'Client name already exists.' });
}
@@ -306,14 +307,14 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: command,
clientName: clientName,
clientName: selectedClientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
} else {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: command,
clientName: clientName,
clientName: selectedClientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
@@ -329,7 +330,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: clientName,
clientName: selectedClientName,
ok: true,
liveOnly: true
});
@@ -339,7 +340,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
`UPDATE d_onboarding_devices
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[clientName, bindingDeviceId]
[selectedClientName, bindingDeviceId]
);
if (!updateResult.affectedRows) {
return res.status(404).json({ error: 'Client not found' });
@@ -351,7 +352,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: clientName,
clientName: selectedClientName,
ok: true
});
});
@@ -360,42 +361,68 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (command === 'moveclient') {
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
let physicalPlayerId = '';
let registeredPlayerBaseUrl = '';
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 targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
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' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
if (submittedPlayerBaseUrl) {
if (livePlayerBaseUrl || submittedPlayerBaseUrl) {
const [playerRows] = await pool.query(
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
[submittedPlayerBaseUrl]
'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1',
[livePlayerBaseUrl || submittedPlayerBaseUrl]
);
const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim();
if (registeredPlayerId) {
physicalPlayerId = registeredPlayerId;
}
const registeredPlayer = playerRows[0] || null;
physicalPlayerId = String(registeredPlayer && registeredPlayer.identifier || '').trim();
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) {
return res.status(400).json({ error: 'Registered player identity is required' });
}
const [currentRows] = 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`,
[physicalPlayerId]
);
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
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?
LIMIT 1`,
[physicalPlayerId]
);
}
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').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;
if (onboardingRow) {
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 = '';
const liveConnection = Array.isArray(liveConnections)
const matchedLiveConnection = Array.isArray(liveConnections)
? 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();
return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId;
return candidateConnectionId === connectionId
|| candidateClientId === connectionId
|| candidateConnectionId === physicalPlayerId
|| candidateClientId === physicalPlayerId
|| candidateDeviceId === physicalPlayerId;
}) || liveConnections[0] || null
: null;
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
const sourcePlayerBaseUrl = registeredPlayerBaseUrl
|| normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl);
if (sourcePlayerBaseUrl) {
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
}
@@ -505,8 +537,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
connectionId: connectionId || null,
screenSlug: targetScreenSlug
});
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
if (sourcePlayerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, sourcePlayerBaseUrl, {
command: 'redirect',
url: targetPlayerUrl,
moveToken: moveToken || null
@@ -544,8 +576,17 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
const liveConnections = await fetchScreenConnections(slug);
const result = playerBaseUrl
? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
const liveConnection = connectionId
? 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);
if (typeof broadcastDashboardState === 'function') {
+2
View File
@@ -1,3 +1,5 @@
// Web routes for pairing players and completing onboarding device bindings.
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
+1
View File
@@ -182,6 +182,7 @@ function registerSignageRoutes(app, deps) {
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable,
findAvailableClientName: deps.findAvailableClientName,
withClientNameReservation: deps.withClientNameReservation,
broadcastDashboardState: deps.broadcastDashboardState,
requirePermission: deps.requirePermission
@@ -3,7 +3,7 @@
<div class="card-body">
{{#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-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}}
<div class="small text-body-secondary">Daily and hourly forecasts will appear here after the first successful fetch.</div>
{{/if}}
@@ -183,7 +183,7 @@
var backgroundGradient = '';
try {
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 }; });
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) {