Release v2.14.1
This commit is contained in:
1 parent
635e1a9942
commit
c3299abaac
174 files changed
+4463
-1667
No files matched your search
@@ -2,6 +2,50 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.14.1 - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added a configurable text size for chart axis labels, legends, and tooltips.
|
||||
|
||||
### Changed
|
||||
|
||||
- Standardized first-party Node.js imports on native `#src` and `#root` package aliases across the application and player/web bundle manifests, removing the process-wide resolver shim.
|
||||
- Extracted player bridge socket and screen-snapshot state into a dedicated module.
|
||||
- Consolidated player rich-text rendering, slide normalization, and media helpers so server and browser paths share implementations.
|
||||
- Warmed the next slide's video element and promoted that same element when the slide becomes active, avoiding a duplicate video request.
|
||||
- Deferred playlist updates until the current slide reaches its next transition, including forced refresh notifications.
|
||||
- Limited announcement polling to WebSocket fallback and coalesced duplicate playlist refresh requests.
|
||||
- Added content fingerprints to player runtime script URLs and refreshed those scripts network-first while retaining offline cache fallback.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored missing player rendering helpers used by template slides, including animation decoration, media-source selection, and canvas sizing.
|
||||
- Avoided a redundant playlist fetch on reload when cached playlist metadata is missing source data.
|
||||
- Kept player reloads on the current screen when a reconnect encounters a transient client-ID conflict.
|
||||
- Preserved safe video startup, pause, and resume behavior while applying queued playlist changes.
|
||||
|
||||
## 2.14.0 - 2026-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added configurable chart data sources, multi-series line charts, chart type and source options, value ordering, grid line colors, and text colors across editor, popout preview, and player rendering.
|
||||
- Added player playlist and playback recovery for cached snapshots, offline states, stalled media, failed image/video loads, and service-worker cache failures.
|
||||
- Added deterministic image derivatives for large uploads while preserving the original file and preventing generated variants from exceeding the original dimensions or file size.
|
||||
- Added immediate template background uploads with shared media validation, variant generation, cleanup of abandoned uploads, and permission checks.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved player handling of large images and videos across mixed-resolution deployments, including 8K displays.
|
||||
- Hardened player render cancellation, preload cleanup, offline playlist options, and service-worker cache retention, and consolidated slide timing in the transition module.
|
||||
- Separated player command handling from DOM transitions and region lifecycle dispatch.
|
||||
- Cancelled obsolete video starts and readiness listeners during teardown, preserved outgoing video frames through crossfades, and guarded interrupted transitions against stale callbacks.
|
||||
- Kept playlist refreshes from restarting active or paused slide timers and made service-worker cache writes best-effort so storage failures do not discard successful network responses.
|
||||
- Updated upload validation to reject oversized files immediately and include the active configured limit in warning messages.
|
||||
- Centralized shared browser upload formatting and notification helpers used by the media library, slide editor, and template editor.
|
||||
- Removed the obsolete image-dimension setting and aligned media upload behavior across slides, templates, and the Media Library.
|
||||
- Updated template background previews to reflect server-accepted uploads only, with failed and abandoned uploads cleaned up through the shared flow.
|
||||
|
||||
## 2.13.3 - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.13.3",
|
||||
"version": "2.14.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
"node": ">=26.0.0"
|
||||
},
|
||||
"main": "src/common.js",
|
||||
"imports": {
|
||||
"#src/*": "./src/*.js",
|
||||
"#root/*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/player.js",
|
||||
"start:player": "node -r dotenv/config src/player.js"
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.13.3",
|
||||
"version": "2.14.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
"node": ">=26.0.0"
|
||||
},
|
||||
"main": "src/common.js",
|
||||
"imports": {
|
||||
"#src/*": "./src/*.js",
|
||||
"#root/*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/web.js",
|
||||
"start:web": "node -r dotenv/config src/web.js"
|
||||
|
||||
Generated
+21
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.13.3",
|
||||
"version": "2.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.13.3",
|
||||
"version": "2.14.1",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
@@ -589,6 +590,12 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.0.tgz",
|
||||
@@ -1043,6 +1050,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.13.3",
|
||||
"version": "2.14.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
@@ -11,6 +11,10 @@
|
||||
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
|
||||
},
|
||||
"main": "src/common.js",
|
||||
"imports": {
|
||||
"#src/*": "./src/*.js",
|
||||
"#root/*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/web.js",
|
||||
"start:web": "node -r dotenv/config src/web.js",
|
||||
@@ -23,6 +27,7 @@
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
|
||||
+2
-25
@@ -1,30 +1,7 @@
|
||||
// Module-resolution shims for the app's `#root` and `#src` aliases.
|
||||
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = __dirname;
|
||||
const resolveFilename = Module._resolveFilename;
|
||||
|
||||
Module._resolveFilename = function (request, parent, isMain, options) {
|
||||
if (request === '#root') {
|
||||
request = rootDir;
|
||||
} else if (request.startsWith('#root/')) {
|
||||
request = path.join(rootDir, request.slice('#root/'.length));
|
||||
} else if (request === '#src') {
|
||||
request = srcDir;
|
||||
} else if (request.startsWith('#src/')) {
|
||||
request = path.join(srcDir, request.slice('#src/'.length));
|
||||
}
|
||||
|
||||
return resolveFilename.call(this, request, parent, isMain, options);
|
||||
};
|
||||
|
||||
const dbCommon = require('#src/db/common');
|
||||
const db = require('#src/db');
|
||||
const db = require('#src/db/index');
|
||||
const dbBootstrap = require('#src/db/bootstrap');
|
||||
const data = require('#src/data');
|
||||
const data = require('#src/data/index');
|
||||
const player = require('#src/player/render');
|
||||
const listQuery = require('#src/web/lib/list-query');
|
||||
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Announcement data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
const {
|
||||
ANNOUNCEMENT_ICON_KEYS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
|
||||
} = require('./announcement-icons');
|
||||
const { validateMaxLength } = require('./utils');
|
||||
} = require('#src/data/announcement-icons');
|
||||
const { validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const SHORT_LABEL_MAX_LENGTH = 255;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Application-wide settings defaults and persistence helpers.
|
||||
|
||||
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
|
||||
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('#src/data/announcement-icons');
|
||||
|
||||
const SETTING_DEFINITIONS = [
|
||||
{ key: 'app.name', type: 'string', defaultValue: 'Pulse Signage' },
|
||||
|
||||
@@ -48,7 +48,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
|
||||
moveclient: 'Move client',
|
||||
setclientname: 'Rename client'
|
||||
});
|
||||
const { fetchAppSettings } = require('./app-settings');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
function formatUserAgentLabel(userAgent) {
|
||||
const value = String(userAgent || '').trim();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
const MAX_CANVAS_SIZE_DIMENSION = 16384;
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
|
||||
+14
-14
@@ -1,19 +1,19 @@
|
||||
// Aggregated exports for the shared data access layer.
|
||||
|
||||
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
|
||||
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
|
||||
const { fetchPlayerRegistrations } = require('./player-registry');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
|
||||
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('#src/data/admin');
|
||||
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('#src/data/announcements');
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('#src/data/announcement-icons');
|
||||
const { fetchPlaylistById } = require('#src/data/playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('#src/data/timetables');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('#src/data/rss-feeds');
|
||||
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('#src/data/weather');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('#src/data/screens');
|
||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('#src/data/templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('#src/data/canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('#src/data/slides');
|
||||
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('#src/data/utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Persistent player registration and heartbeat helpers shared by the web app and bridge.
|
||||
// These utilities intentionally keep URL normalization and heartbeat staleness rules in one place
|
||||
// so the web routing logic and media sync tasks all make the same decision about a live player.
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
return String(value || '')
|
||||
@@ -11,6 +13,34 @@ function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isLocalLikeBaseUrl(value) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return host === 'localhost'
|
||||
|| host === '127.0.0.1'
|
||||
|| host === '::1'
|
||||
|| host === 'host.docker.internal'
|
||||
|| host === 'player'
|
||||
|| host === 'web'
|
||||
|| host === 'player-bridge'
|
||||
|| host.endsWith('.local')
|
||||
|| host.endsWith('.internal')
|
||||
|| host.endsWith('.docker.internal');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value) {
|
||||
return String(value || '').trim().slice(0, 255);
|
||||
}
|
||||
@@ -143,6 +173,9 @@ async function recordPlayerHeartbeat(pool, options) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeBaseUrl: normalizeBaseUrl,
|
||||
isLocalLikeBaseUrl: isLocalLikeBaseUrl,
|
||||
isRecentPlayerRegistration: isRecentPlayerRegistration,
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
normalizeIdentifier: normalizeIdentifier,
|
||||
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
|
||||
+61
-4
@@ -1,10 +1,10 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe, validateMaxLength } = require('./utils');
|
||||
const { fetchTemplateById } = require('#src/data/templates');
|
||||
const { parseJsonSafe, validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const TITLE_MAX_LENGTH = 255;
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
const { buildQrCodeContent } = require('#src/data/qr-code');
|
||||
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
@@ -474,7 +474,64 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) {
|
||||
} else if (region.region_type === 'chart') {
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
let manualRows = [];
|
||||
let datasets = [];
|
||||
try {
|
||||
const parsedRows = JSON.parse(String(body[`region_chart_manual_rows_${region.id}`] || '[]'));
|
||||
manualRows = Array.isArray(parsedRows) ? parsedRows.slice(0, 500) : [];
|
||||
} catch (_error) {
|
||||
manualRows = Array.isArray(current.manual_rows) ? current.manual_rows : [];
|
||||
}
|
||||
try {
|
||||
const parsedDatasets = JSON.parse(String(body[`region_chart_datasets_${region.id}`] || '[]'));
|
||||
datasets = Array.isArray(parsedDatasets) ? parsedDatasets.slice(0, 12) : [];
|
||||
} catch (_error) {
|
||||
datasets = Array.isArray(current.datasets) ? current.datasets : [];
|
||||
}
|
||||
const colors = String(body[`region_chart_colors_${region.id}`] === undefined ? (Array.isArray(current.colors) ? current.colors.join(',') : '') : body[`region_chart_colors_${region.id}`] || '')
|
||||
.split(',')
|
||||
.map((color) => String(color || '').trim())
|
||||
.filter((color) => /^#[0-9a-fA-F]{3,8}$/.test(color))
|
||||
.slice(0, 24);
|
||||
const chartSourceKind = String(body[`region_chart_source_kind_${region.id}`] || current.source_kind || 'manual') === 'api' ? 'api' : 'manual';
|
||||
const chartOptions = {
|
||||
legend: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic')) ? String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic') : 'automatic',
|
||||
begin_at_zero: body[`region_chart_begin_at_zero_control_${region.id}`] === undefined ? current.chart_options?.begin_at_zero !== false : body[`region_chart_begin_at_zero_${region.id}`] === 'on',
|
||||
y_axis_min: String(body[`region_chart_y_axis_min_${region.id}`] === undefined ? current.chart_options?.y_axis_min || '' : body[`region_chart_y_axis_min_${region.id}`] || '').trim(),
|
||||
y_axis_max: String(body[`region_chart_y_axis_max_${region.id}`] === undefined ? current.chart_options?.y_axis_max || '' : body[`region_chart_y_axis_max_${region.id}`] || '').trim(),
|
||||
line_points: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic')) ? String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic') : 'automatic',
|
||||
line_fill: body[`region_chart_line_fill_${region.id}`] === 'on',
|
||||
bar_orientation: String(body[`region_chart_bar_orientation_${region.id}`] || current.chart_options?.bar_orientation || 'vertical') === 'horizontal' ? 'horizontal' : 'vertical',
|
||||
value_format: ['number', 'percent', 'currency'].includes(String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number')) ? String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number') : 'number',
|
||||
decimal_places: Math.max(0, Math.min(4, Number(body[`region_chart_decimal_places_${region.id}`] === undefined ? current.chart_options?.decimal_places || 0 : body[`region_chart_decimal_places_${region.id}`] || 0) || 0)),
|
||||
text_size: Math.max(8, Math.min(48, Number(body[`region_chart_text_size_${region.id}`] === undefined ? current.chart_options?.text_size || 12 : body[`region_chart_text_size_${region.id}`] || 12) || 12)),
|
||||
grid_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color || '')) ? String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color) : '#000000',
|
||||
text_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color || '')) ? String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color) : '#000000'
|
||||
};
|
||||
const sourceOptions = {
|
||||
sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].includes(String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none')) ? String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none') : 'none',
|
||||
limit: Math.max(0, Math.min(500, Number(body[`region_chart_source_limit_${region.id}`] === undefined ? current.source_options?.limit || 0 : body[`region_chart_source_limit_${region.id}`] || 0) || 0)),
|
||||
missing: ['skip', 'zero', 'gap'].includes(String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap')) ? String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap') : 'gap'
|
||||
};
|
||||
content[region.region_key] = {
|
||||
type: 'chart',
|
||||
chart_type: ['line', 'bar', 'doughnut', 'pie'].includes(String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar')) ? String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar') : 'bar',
|
||||
source_kind: chartSourceKind,
|
||||
source_id: chartSourceKind === 'api' ? (body[`region_chart_source_id_${region.id}`] === undefined ? (current.source_id || null) : (body[`region_chart_source_id_${region.id}`] === '' ? null : Number(body[`region_chart_source_id_${region.id}`]))) : null,
|
||||
items_path: String(body[`region_chart_items_path_${region.id}`] === undefined ? current.items_path || '' : body[`region_chart_items_path_${region.id}`] || '').trim(),
|
||||
label_path: String(body[`region_chart_label_path_${region.id}`] === undefined ? current.label_path || '' : body[`region_chart_label_path_${region.id}`] || '').trim(),
|
||||
value_path: String(body[`region_chart_value_path_${region.id}`] === undefined ? current.value_path || '' : body[`region_chart_value_path_${region.id}`] || '').trim(),
|
||||
dataset_label: String(body[`region_chart_dataset_label_${region.id}`] === undefined ? current.dataset_label || 'Value' : body[`region_chart_dataset_label_${region.id}`] || 'Value').trim() || 'Value',
|
||||
color: /^#[0-9a-fA-F]{3,8}$/.test(String(body[`region_chart_color_${region.id}`] || current.color || '')) ? String(body[`region_chart_color_${region.id}`]) : (current.color || '#36a269'),
|
||||
datasets,
|
||||
colors,
|
||||
manual_rows: manualRows,
|
||||
chart_options: chartOptions,
|
||||
source_options: sourceOptions
|
||||
};
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'chart'].includes(String(region.region_type || '').trim())) {
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
const suffix = '_' + region.id;
|
||||
const generic = {};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils');
|
||||
const animationPresets = require('#src/web/public/js/templates/animation-presets');
|
||||
|
||||
const TEMPLATE_NAME_MAX_LENGTH = 255;
|
||||
const REGION_NAME_MAX_LENGTH = 255;
|
||||
@@ -49,7 +49,8 @@ function normalizeBackgroundGradient(value) {
|
||||
|
||||
function normalizeTemplateRegionType(value) {
|
||||
const rawType = String(value || 'text').trim();
|
||||
return rawType || 'text';
|
||||
const normalizedType = rawType.toLowerCase();
|
||||
return ['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'timetable', 'time-date', 'chart'].includes(normalizedType) ? rawType : 'text';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionLockRatio(value) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Weather location data access and form normalization.
|
||||
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
const { fetchAppSettings } = require('./app-settings');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const LOCATION_MAX_LENGTH = 255;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Schema initialization, migration execution, and database bootstrap helpers.
|
||||
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
|
||||
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('#src/db/migrations');
|
||||
|
||||
// Snapshot only: keep this file aligned with the current schema state.
|
||||
async function ensureSchema(pool, options) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
// This bridge state owns the in-memory socket and screen snapshot lifecycle so the
|
||||
// HTTP/WebSocket bootstrap stays focused on routing instead of mutable state.
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function createPlayerBridgeState() {
|
||||
const playerSockets = new Map();
|
||||
const screenSnapshotCache = new Map();
|
||||
const screenSnapshotSourcesBySlug = new Map();
|
||||
const screenSnapshotSubscribersBySlug = new Map();
|
||||
const screenPlayerDeviceIds = new Map();
|
||||
|
||||
function getScreenSnapshotSourceBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!screenSnapshotSourcesBySlug.has(key)) {
|
||||
screenSnapshotSourcesBySlug.set(key, new Map());
|
||||
}
|
||||
|
||||
return screenSnapshotSourcesBySlug.get(key);
|
||||
}
|
||||
|
||||
function getScreenSnapshotSubscriberBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!screenSnapshotSubscribersBySlug.has(key)) {
|
||||
screenSnapshotSubscribersBySlug.set(key, new Set());
|
||||
}
|
||||
|
||||
return screenSnapshotSubscribersBySlug.get(key);
|
||||
}
|
||||
|
||||
function storeScreenSnapshot(slug, connections, deviceIds) {
|
||||
const key = String(slug || '').trim();
|
||||
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.filter(Boolean).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean) : [];
|
||||
|
||||
screenSnapshotCache.set(key, {
|
||||
slug: key,
|
||||
count: normalizedConnections.length,
|
||||
connections: normalizedConnections
|
||||
});
|
||||
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
|
||||
}
|
||||
|
||||
function buildMergedScreenSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
|
||||
const connections = [];
|
||||
const deviceIds = [];
|
||||
|
||||
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
|
||||
sourceBucket.forEach(function (payload) {
|
||||
if (payload && Array.isArray(payload.connections)) {
|
||||
connections.push.apply(connections, payload.connections);
|
||||
}
|
||||
});
|
||||
sourceBucket.forEach(function (_payload, sourceKey) {
|
||||
deviceIds.push(sourceKey);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
slug: key,
|
||||
count: connections.length,
|
||||
connections: connections,
|
||||
deviceIds: deviceIds
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastScreenSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const snapshot = buildMergedScreenSnapshot(key);
|
||||
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
|
||||
|
||||
const bucket = screenSnapshotSubscribersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: key,
|
||||
connections: snapshot.connections,
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setScreenSnapshotSource(slug, sourceKey, connections) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
if (!key || !normalizedSourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucket = getScreenSnapshotSourceBucket(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.set(normalizedSourceKey, {
|
||||
slug: key,
|
||||
connections: Array.isArray(connections) ? connections : []
|
||||
});
|
||||
broadcastScreenSnapshot(key);
|
||||
}
|
||||
|
||||
function clearScreenSnapshotSource(slug, sourceKey) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
const bucket = screenSnapshotSourcesBySlug.get(key);
|
||||
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.delete(normalizedSourceKey);
|
||||
if (!bucket.size) {
|
||||
screenSnapshotSourcesBySlug.delete(key);
|
||||
}
|
||||
|
||||
broadcastScreenSnapshot(key);
|
||||
}
|
||||
|
||||
function clearPlayerSnapshotSources(sourceKey) {
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
if (!normalizedSourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
|
||||
clearScreenSnapshotSource(slug, normalizedSourceKey);
|
||||
});
|
||||
}
|
||||
|
||||
function getScreenSnapshotCount(slug) {
|
||||
const snapshot = screenSnapshotCache.get(String(slug || '').trim()) || { count: 0 };
|
||||
return Number(snapshot.count || 0);
|
||||
}
|
||||
|
||||
return {
|
||||
playerSockets: playerSockets,
|
||||
screenSnapshotCache: screenSnapshotCache,
|
||||
screenSnapshotSourcesBySlug: screenSnapshotSourcesBySlug,
|
||||
screenSnapshotSubscribersBySlug: screenSnapshotSubscribersBySlug,
|
||||
screenPlayerDeviceIds: screenPlayerDeviceIds,
|
||||
getScreenSnapshotSourceBucket: getScreenSnapshotSourceBucket,
|
||||
getScreenSnapshotSubscriberBucket: getScreenSnapshotSubscriberBucket,
|
||||
storeScreenSnapshot: storeScreenSnapshot,
|
||||
buildMergedScreenSnapshot: buildMergedScreenSnapshot,
|
||||
broadcastScreenSnapshot: broadcastScreenSnapshot,
|
||||
setScreenSnapshotSource: setScreenSnapshotSource,
|
||||
clearScreenSnapshotSource: clearScreenSnapshotSource,
|
||||
clearPlayerSnapshotSources: clearPlayerSnapshotSources,
|
||||
getScreenSnapshotCount: getScreenSnapshotCount
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlayerBridgeState: createPlayerBridgeState
|
||||
};
|
||||
+21
-161
@@ -6,14 +6,14 @@ const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('../common');
|
||||
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const common = require('#src/common');
|
||||
const { verifyRequestAuth, createRequestAuthHeaders, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||
const { createPlayerPlaylistService } = require('../player/playlist');
|
||||
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
|
||||
const { createStyledQrCodeSvg } = require('../data/qr-code');
|
||||
const { verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { collectLocalControlUsers } = require('../web/lib/local-control-users');
|
||||
const { createPlayerBridgeState } = require('#src/player-bridge/connection-state');
|
||||
const { createPlayerPlaylistService } = require('#src/player/playlist');
|
||||
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('#src/player/onboarding/index');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
|
||||
|
||||
|
||||
function createThinClientConfig() {
|
||||
@@ -187,6 +187,7 @@ async function start() {
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const config = createThinClientConfig();
|
||||
const bridgeState = createPlayerBridgeState();
|
||||
const localPlayerInternalUrl = String(process.env.LOCAL_PLAYER_INTERNAL_URL || process.env.PLAYER_INTERNAL_URL || '').trim().replace(/\/$/, '');
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
@@ -197,133 +198,21 @@ async function start() {
|
||||
app.use(express.json());
|
||||
const playersWs = new WebSocketServer({ noServer: true });
|
||||
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
|
||||
const playerSockets = new Map();
|
||||
const screenSnapshotCache = new Map();
|
||||
const screenSnapshotSourcesBySlug = new Map();
|
||||
const screenSnapshotSubscribersBySlug = new Map();
|
||||
const screenPlayerDeviceIds = new Map();
|
||||
const playerSockets = bridgeState.playerSockets;
|
||||
const screenSnapshotCache = bridgeState.screenSnapshotCache;
|
||||
const screenSnapshotSourcesBySlug = bridgeState.screenSnapshotSourcesBySlug;
|
||||
const screenSnapshotSubscribersBySlug = bridgeState.screenSnapshotSubscribersBySlug;
|
||||
const screenPlayerDeviceIds = bridgeState.screenPlayerDeviceIds;
|
||||
const pendingPlayerCommands = new Map();
|
||||
|
||||
function getScreenSnapshotSourceBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!screenSnapshotSourcesBySlug.has(key)) {
|
||||
screenSnapshotSourcesBySlug.set(key, new Map());
|
||||
}
|
||||
|
||||
return screenSnapshotSourcesBySlug.get(key);
|
||||
}
|
||||
|
||||
function getScreenSnapshotSubscriberBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!screenSnapshotSubscribersBySlug.has(key)) {
|
||||
screenSnapshotSubscribersBySlug.set(key, new Set());
|
||||
}
|
||||
|
||||
return screenSnapshotSubscribersBySlug.get(key);
|
||||
}
|
||||
|
||||
function buildMergedScreenSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
|
||||
const connections = [];
|
||||
const deviceIds = [];
|
||||
|
||||
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
|
||||
sourceBucket.forEach(function (payload) {
|
||||
if (payload && Array.isArray(payload.connections)) {
|
||||
connections.push.apply(connections, payload.connections);
|
||||
}
|
||||
});
|
||||
sourceBucket.forEach(function (_payload, sourceKey) {
|
||||
deviceIds.push(sourceKey);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
slug: key,
|
||||
count: connections.length,
|
||||
connections: connections,
|
||||
deviceIds: deviceIds
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastScreenSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const snapshot = buildMergedScreenSnapshot(key);
|
||||
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
|
||||
|
||||
const bucket = screenSnapshotSubscribersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: key,
|
||||
connections: snapshot.connections,
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setScreenSnapshotSource(slug, sourceKey, connections) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
if (!key || !normalizedSourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucket = getScreenSnapshotSourceBucket(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.set(normalizedSourceKey, {
|
||||
slug: key,
|
||||
connections: Array.isArray(connections) ? connections : []
|
||||
});
|
||||
broadcastScreenSnapshot(key);
|
||||
}
|
||||
|
||||
function clearScreenSnapshotSource(slug, sourceKey) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
const bucket = screenSnapshotSourcesBySlug.get(key);
|
||||
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.delete(normalizedSourceKey);
|
||||
if (!bucket.size) {
|
||||
screenSnapshotSourcesBySlug.delete(key);
|
||||
}
|
||||
|
||||
broadcastScreenSnapshot(key);
|
||||
}
|
||||
|
||||
function clearPlayerSnapshotSources(sourceKey) {
|
||||
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||
if (!normalizedSourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
|
||||
clearScreenSnapshotSource(slug, normalizedSourceKey);
|
||||
});
|
||||
}
|
||||
const getScreenSnapshotSourceBucket = bridgeState.getScreenSnapshotSourceBucket;
|
||||
const getScreenSnapshotSubscriberBucket = bridgeState.getScreenSnapshotSubscriberBucket;
|
||||
const buildMergedScreenSnapshot = bridgeState.buildMergedScreenSnapshot;
|
||||
const broadcastScreenSnapshot = bridgeState.broadcastScreenSnapshot;
|
||||
const setScreenSnapshotSource = bridgeState.setScreenSnapshotSource;
|
||||
const clearScreenSnapshotSource = bridgeState.clearScreenSnapshotSource;
|
||||
const clearPlayerSnapshotSources = bridgeState.clearPlayerSnapshotSources;
|
||||
const storeScreenSnapshot = bridgeState.storeScreenSnapshot;
|
||||
|
||||
async function fetchPlayerSnapshotRegistrations() {
|
||||
if (typeof common.fetchPlayerRegistrations === 'function') {
|
||||
@@ -471,35 +360,6 @@ async function start() {
|
||||
return Array.isArray(deviceIds) ? deviceIds.slice() : [];
|
||||
}
|
||||
|
||||
function storeScreenSnapshot(slug, connections, deviceIds) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedConnections = [];
|
||||
const connectionIndexes = new Map();
|
||||
(Array.isArray(connections) ? connections : []).forEach(function (connection) {
|
||||
const identity = connection && typeof connection === 'object'
|
||||
? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|')
|
||||
: '';
|
||||
if (!identity || !connectionIndexes.has(identity)) {
|
||||
if (identity) {
|
||||
connectionIndexes.set(identity, normalizedConnections.length);
|
||||
}
|
||||
normalizedConnections.push(connection);
|
||||
return;
|
||||
}
|
||||
normalizedConnections[connectionIndexes.get(identity)] = connection;
|
||||
});
|
||||
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
|
||||
return normalizeDeviceId(value);
|
||||
}).filter(Boolean) : [];
|
||||
|
||||
screenSnapshotCache.set(key, {
|
||||
slug: key,
|
||||
count: normalizedConnections.length,
|
||||
connections: normalizedConnections
|
||||
});
|
||||
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
|
||||
}
|
||||
|
||||
app.get('/health', function (_req, res) {
|
||||
res.json({ ok: true, service: 'player-bridge' });
|
||||
});
|
||||
|
||||
+10
-10
@@ -5,19 +5,19 @@ const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
|
||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
const { getPlayerRuntimeScripts } = require('./player/render-helpers');
|
||||
const common = require('#src/common');
|
||||
const { createPlayerRuntime } = require('#src/player/runtime');
|
||||
const { createPlayerPlaylistService } = require('#src/player/playlist');
|
||||
const { createRtmpStreamService } = require('#src/player/modules/rtmp-streams');
|
||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('#src/player/onboarding/index');
|
||||
const { createOnboardingStore } = require('#src/player/onboarding/store');
|
||||
const { registerPlayerRoutes } = require('#src/player/routes');
|
||||
const { getPlayerRuntimeScripts } = require('#src/player/render-helpers');
|
||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||
const { createLocalControlService } = require('./player/local-control');
|
||||
const { createLocalControlService } = require('#src/player/local-control');
|
||||
|
||||
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
@@ -627,7 +627,7 @@ async function start() {
|
||||
await syncDatabaseState();
|
||||
|
||||
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
|
||||
const { upsertPlayerRegistration } = require('./player/onboarding');
|
||||
const { upsertPlayerRegistration } = require('#src/player/onboarding/index');
|
||||
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const { findAvailableClientName, withClientNameReservation } = require('#src/dat
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
|
||||
const { isTransientDbError } = require('./store');
|
||||
const { isTransientDbError } = require('#src/player/onboarding/store');
|
||||
|
||||
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopAnnouncementPolling() {
|
||||
if (announcementPollTimer) {
|
||||
window.clearInterval(announcementPollTimer);
|
||||
announcementPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAnnouncementReconnect() {
|
||||
if (announcementReconnectTimer) {
|
||||
return;
|
||||
@@ -222,6 +229,7 @@
|
||||
announcementSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
stopAnnouncementPolling();
|
||||
void refreshAnnouncementOverlay();
|
||||
};
|
||||
|
||||
@@ -231,6 +239,7 @@
|
||||
|
||||
socket.onclose = function () {
|
||||
announcementSocket = null;
|
||||
scheduleAnnouncementPolling();
|
||||
scheduleAnnouncementReconnect();
|
||||
};
|
||||
|
||||
@@ -247,14 +256,18 @@
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
void refreshAnnouncementOverlay();
|
||||
scheduleAnnouncementPolling();
|
||||
connectAnnouncementSocket();
|
||||
if (!window.WebSocket) {
|
||||
void refreshAnnouncementOverlay();
|
||||
}
|
||||
}, { once: true });
|
||||
} else {
|
||||
void refreshAnnouncementOverlay();
|
||||
scheduleAnnouncementPolling();
|
||||
connectAnnouncementSocket();
|
||||
if (!window.WebSocket) {
|
||||
void refreshAnnouncementOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', function () {
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
let slideRenderRequestId = 0;
|
||||
let preloadContainer = null;
|
||||
let preloadSignature = '';
|
||||
let mediaPreloadSignature = '';
|
||||
let mediaPreloadElements = [];
|
||||
let commandSocket = null;
|
||||
let commandReconnectTimer = null;
|
||||
let commandStateTimer = null;
|
||||
@@ -47,6 +49,7 @@
|
||||
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
|
||||
const commandClientStorageKey = 'pulse-signage-player-client-id';
|
||||
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
|
||||
const playbackStateStorageKey = 'pulse-signage-player-playback-state:' + slug;
|
||||
const initialPlaylistSnapshot = loadPlaylistSnapshot();
|
||||
let offlineBanner = null;
|
||||
let offlineBannerVisible = false;
|
||||
@@ -104,6 +107,7 @@
|
||||
}));
|
||||
}
|
||||
|
||||
startPlayerWatchdog();
|
||||
window.addEventListener('error', function (event) {
|
||||
logDebug('Player script error.', String(event.message || event.error || 'unknown error'), 'error');
|
||||
});
|
||||
@@ -134,7 +138,6 @@
|
||||
|
||||
window.addEventListener('online', function () {
|
||||
syncOfflineBanner();
|
||||
markRefreshHealthy();
|
||||
refresh();
|
||||
});
|
||||
|
||||
@@ -147,7 +150,6 @@
|
||||
syncOfflineBanner();
|
||||
syncScreenWakeLock();
|
||||
if (document.visibilityState === 'visible') {
|
||||
markRefreshHealthy();
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
@@ -175,6 +177,7 @@
|
||||
skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp,
|
||||
etag: currentPlaylistEtag
|
||||
});
|
||||
restorePlaybackState();
|
||||
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(getActiveSlidesFrom(slides), index);
|
||||
@@ -185,6 +188,7 @@
|
||||
} else {
|
||||
if (initialPlaylistSnapshot && applyPlaylistSnapshot(initialPlaylistSnapshot)) {
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(initialPlaylistSnapshot.skipUnavailableRtmp);
|
||||
restorePlaybackState();
|
||||
showCurrent();
|
||||
}
|
||||
syncBlackoutState();
|
||||
|
||||
+45
-2
@@ -4,7 +4,9 @@ const crypto = require('crypto');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
|
||||
const { createStyledQrCodeDataUrl } = require('#src/data/qr-code');
|
||||
|
||||
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
|
||||
|
||||
function createPlayerPlaylistService(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
@@ -64,6 +66,28 @@ function createPlayerPlaylistService(options) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function getImageVariants(mediaPath) {
|
||||
const normalizedPath = String(mediaPath || '').trim().split(/[?#]/)[0];
|
||||
if (!mediaDir || !/^\/media\/uploads\/[^/]+\.(?:jpe?g|png|webp)$/i.test(normalizedPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileName = path.basename(normalizedPath);
|
||||
const extension = path.extname(fileName);
|
||||
const baseName = path.basename(fileName, extension);
|
||||
const variants = [];
|
||||
for (const width of IMAGE_VARIANT_DIMENSIONS) {
|
||||
const variantName = baseName + '.variant-' + width + extension;
|
||||
const variantPath = path.join(mediaDir, 'uploads', variantName);
|
||||
try {
|
||||
await fs.promises.access(variantPath, fs.constants.F_OK);
|
||||
variants.push({ width: width, url: '/media/uploads/' + variantName });
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
function getPlaceholderImageExpressions(value, output) {
|
||||
const expressions = output || [];
|
||||
const source = String(value || '');
|
||||
@@ -281,6 +305,25 @@ function createPlayerPlaylistService(options) {
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(Object.keys(content).map(async function (key) {
|
||||
const region = content[key];
|
||||
if (!region || typeof region !== 'object' || !['image', 'video'].includes(String(region.type || '').trim().toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
const variants = await getImageVariants(region.value);
|
||||
if (variants.length) {
|
||||
region.media_variants = variants;
|
||||
}
|
||||
}));
|
||||
|
||||
const template = slide.template_id ? templatesById[slide.template_id] || null : null;
|
||||
if (template && template.background_image_path && !template.background_image_variants) {
|
||||
const variants = await getImageVariants(template.background_image_path);
|
||||
if (variants.length) {
|
||||
template.background_image_variants = variants;
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') {
|
||||
@@ -310,7 +353,7 @@ function createPlayerPlaylistService(options) {
|
||||
disable_audio: disableAudio,
|
||||
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
template: template,
|
||||
content: content
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
var ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
var ANNOUNCEMENT_ICONS = Array.isArray(window.pulseAnnouncementIconKeys) ? window.pulseAnnouncementIconKeys : [];
|
||||
var DEFAULT_ANNOUNCEMENT_ICON = String(window.pulseAnnouncementDefaultIconKey || ANNOUNCEMENT_ICONS[0] || '').trim().toLowerCase();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
var escapeHtml = window.pulsePlayerRenderingShared.escapeHtml;
|
||||
|
||||
function normalizeAnnouncementType(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
|
||||
@@ -7,6 +7,7 @@ function getCurrentViewport() {
|
||||
}
|
||||
|
||||
var commandHeartbeatTimer = null;
|
||||
var commandReconnectDelayMs = 0;
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
@@ -61,137 +62,6 @@ function scheduleViewportRenderUpdate() {
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
module[lifecycleName](root);
|
||||
} catch (_error) {
|
||||
// Keep slide rendering resilient if a region lifecycle hook fails.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'destroyRegion');
|
||||
}
|
||||
|
||||
function initializeRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'initRegion');
|
||||
}
|
||||
|
||||
// Swap slide markup with optional fade animation.
|
||||
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
|
||||
clearSlideTransitionTimer();
|
||||
destroyRegionInstances(app);
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app);
|
||||
}
|
||||
|
||||
function schedulePostRenderSetup(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeRegionInstances(root);
|
||||
if (typeof initializeSlideMedia === 'function') {
|
||||
initializeSlideMedia(root, delayMs);
|
||||
}
|
||||
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(root, 'intro');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
schedulePostRenderSetup(app, 0);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||
var existingShells = topLevelChildren.filter(function (child) {
|
||||
return child && child.classList && child.classList.contains('slide-shell');
|
||||
});
|
||||
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||
|
||||
if (existingShells.length > 1) {
|
||||
existingShells.slice(0, -1).forEach(function (shell) {
|
||||
if (shell && shell.parentNode) {
|
||||
shell.parentNode.removeChild(shell);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell slide-shell-entering';
|
||||
nextShell.style.zIndex = '0';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
app.appendChild(nextShell);
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.classList.remove('is-visible');
|
||||
previousShell.classList.add('is-exiting');
|
||||
previousShell.style.zIndex = '1';
|
||||
previousShell.style.opacity = '1';
|
||||
|
||||
app.appendChild(nextShell);
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
});
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.zIndex = '1';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeLengthMs);
|
||||
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
// Mirror blackout state onto the document body.
|
||||
function syncBlackoutState() {
|
||||
document.body.classList.toggle('screen-blackout', isBlackout);
|
||||
@@ -207,11 +77,13 @@ function setPaused(nextPaused) {
|
||||
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
|
||||
isPaused = true;
|
||||
clearSlideTimer();
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
|
||||
isPaused = false;
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
if (!slides.length || !lastRenderedSlide) {
|
||||
return;
|
||||
@@ -230,6 +102,7 @@ function setBlackout(nextBlackout) {
|
||||
}
|
||||
isBlackout = normalized;
|
||||
syncBlackoutState();
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
}
|
||||
|
||||
@@ -357,10 +230,6 @@ function handleCommandMessage(rawMessage) {
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-id-conflict') {
|
||||
handleClientIdConflict();
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-name-updated') {
|
||||
if (payload.clientName) {
|
||||
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||
@@ -437,23 +306,31 @@ function handleCommandMessage(rawMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleClientIdConflict() {
|
||||
var replacementClientId = regenerateCommandClientId();
|
||||
var onboardingUrl = new URL('/', window.location.origin);
|
||||
window.location.replace(onboardingUrl.toString());
|
||||
}
|
||||
|
||||
// Retry the command websocket after a disconnect.
|
||||
function scheduleCommandReconnect() {
|
||||
if (commandReconnectTimer) {
|
||||
return;
|
||||
}
|
||||
var baseDelayMs = commandReconnectDelayMs
|
||||
? Math.min(commandReconnectDelayMs * 2, 30000)
|
||||
: 1000;
|
||||
commandReconnectDelayMs = baseDelayMs;
|
||||
var jitteredDelayMs = Math.round(baseDelayMs * (0.75 + Math.random() * 0.5));
|
||||
commandReconnectTimer = window.setTimeout(function () {
|
||||
commandReconnectTimer = null;
|
||||
connectCommandSocket();
|
||||
}, 5000);
|
||||
}, jitteredDelayMs);
|
||||
}
|
||||
|
||||
window.addEventListener('online', function () {
|
||||
if (commandReconnectTimer) {
|
||||
window.clearTimeout(commandReconnectTimer);
|
||||
commandReconnectTimer = null;
|
||||
}
|
||||
commandReconnectDelayMs = 0;
|
||||
connectCommandSocket();
|
||||
});
|
||||
|
||||
// Open and wire the command websocket connection.
|
||||
function connectCommandSocket() {
|
||||
if (!window.WebSocket) {
|
||||
@@ -466,6 +343,7 @@ function connectCommandSocket() {
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
commandReconnectDelayMs = 0;
|
||||
if (commandHeartbeatTimer) {
|
||||
window.clearInterval(commandHeartbeatTimer);
|
||||
}
|
||||
@@ -495,7 +373,9 @@ function connectCommandSocket() {
|
||||
}
|
||||
commandSocket = null;
|
||||
if (event && event.code === 4009) {
|
||||
handleClientIdConflict();
|
||||
regenerateCommandClientId();
|
||||
commandReconnectDelayMs = 0;
|
||||
scheduleCommandReconnect();
|
||||
return;
|
||||
}
|
||||
scheduleCommandReconnect();
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// Slide media startup and teardown helpers.
|
||||
|
||||
var renderedVideoPlaybackCleanup = new WeakMap();
|
||||
|
||||
function cancelRenderedVideoPlayback(video) {
|
||||
var cleanup = renderedVideoPlaybackCleanup.get(video);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
renderedVideoPlaybackCleanup.delete(video);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
@@ -12,14 +22,37 @@ function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRenderedVideoPlayback(video);
|
||||
var playbackScheduled = false;
|
||||
var playbackCancelled = false;
|
||||
var playbackTimer = null;
|
||||
|
||||
function removeReadinessListeners() {
|
||||
video.removeEventListener('canplay', schedulePlaybackStart);
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
}
|
||||
|
||||
// A cancelled callback can already be queued for dispatch, so it also checks this flag.
|
||||
renderedVideoPlaybackCleanup.set(video, function () {
|
||||
playbackCancelled = true;
|
||||
if (playbackTimer !== null) {
|
||||
window.clearTimeout(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
removeReadinessListeners();
|
||||
});
|
||||
|
||||
// Script-controlled startup aligns playback with the incoming slide's fade timing.
|
||||
video.autoplay = false;
|
||||
video.loop = !(video.dataset && video.dataset.loop === '0');
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
playbackTimer = null;
|
||||
if (playbackCancelled || video.isConnected === false || root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
@@ -29,28 +62,33 @@ function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
}
|
||||
|
||||
function schedulePlaybackStart() {
|
||||
if (playbackScheduled) {
|
||||
// Metadata and canplay can both fire; schedule only one start for this video.
|
||||
if (playbackCancelled || playbackScheduled) {
|
||||
return;
|
||||
}
|
||||
playbackScheduled = true;
|
||||
removeReadinessListeners();
|
||||
if (startDelayMs > 0) {
|
||||
window.setTimeout(startPlayback, startDelayMs);
|
||||
playbackTimer = window.setTimeout(startPlayback, startDelayMs);
|
||||
return;
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
function onLoadedMetadata() {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}
|
||||
|
||||
// HAVE_CURRENT_DATA means a frame is available, not just the video's metadata.
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}, { once: true });
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +125,28 @@ function initializeSlideMedia(root, delayMs) {
|
||||
initializeRenderedVideoPlayback(root, delayMs);
|
||||
}
|
||||
|
||||
function destroySlideMedia(root) {
|
||||
function destroySlideMedia(root, options) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var preserveVideoFrame = Boolean(options && options.preserveVideoFrame);
|
||||
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
cancelRenderedVideoPlayback(video);
|
||||
if (typeof video.pause === 'function') {
|
||||
video.pause();
|
||||
}
|
||||
// Keep the paused frame through a crossfade; release decoder/network resources at removal.
|
||||
if (!preserveVideoFrame) {
|
||||
video.removeAttribute('src');
|
||||
if (typeof video.load === 'function') {
|
||||
video.load();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// RTMP players hold independent HLS resources beyond the lifetime of their DOM nodes.
|
||||
if (typeof destroyRtmpRegions === 'function') {
|
||||
destroyRtmpRegions(root);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
var playlistRefreshInFlight = false;
|
||||
|
||||
// Render the slide at the requested index within the active set.
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
@@ -8,14 +11,14 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
}
|
||||
|
||||
let normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
var slideCount = availableSlides.length;
|
||||
var currentIndex = normalizedIndex;
|
||||
var slide = null;
|
||||
var attempts = 0;
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
|
||||
while (attempts < slideCount) {
|
||||
slide = availableSlides[currentIndex];
|
||||
@@ -35,7 +38,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
}
|
||||
}
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -56,6 +59,13 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
|
||||
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
|
||||
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
|
||||
if (typeof syncSlideMediaPreloads === 'function') {
|
||||
syncSlideMediaPreloads(availableSlides, currentIndex);
|
||||
}
|
||||
if (typeof markPlayerPlaybackProgress === 'function') {
|
||||
markPlayerPlaybackProgress();
|
||||
}
|
||||
savePlaybackState();
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
||||
}
|
||||
@@ -67,20 +77,6 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate the configured time from slide entry to the next transition.
|
||||
function getSlideAdvanceDelay(slide) {
|
||||
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
|
||||
if (slide && slide.use_video_duration) {
|
||||
return currentPlaylistFadeBetweenSlides
|
||||
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
|
||||
: getSlideHoldDelay(durationMs);
|
||||
}
|
||||
if (currentPlaylistFadeBetweenSlides) {
|
||||
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
|
||||
}
|
||||
return getSlideHoldDelay(durationMs);
|
||||
}
|
||||
|
||||
// Promote a deferred playlist update at the next safe point.
|
||||
function applyPendingPlaylistUpdate() {
|
||||
if (!pendingPlaylistUpdate) {
|
||||
@@ -117,15 +113,12 @@ function showCurrent(options) {
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(activeSlides, index);
|
||||
}
|
||||
if (!activeSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(activeSlides, index, options);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
var lastPlaylistRefreshAt = 0;
|
||||
|
||||
function isSlideInList(slide, slideList) {
|
||||
if (!slide || !Array.isArray(slideList)) {
|
||||
return false;
|
||||
@@ -163,8 +156,16 @@ function handleRtmpPlaybackFailure(message, options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch the latest playlist and queue any updates.
|
||||
function refresh(applyImmediately) {
|
||||
// Fetch and queue data updates; rendering and pause/resume own the slide timer.
|
||||
function refresh(applyImmediately, bypassDebounce) {
|
||||
if (playlistRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
if (!bypassDebounce && Date.now() - lastPlaylistRefreshAt < 1000) {
|
||||
return;
|
||||
}
|
||||
playlistRefreshInFlight = true;
|
||||
lastPlaylistRefreshAt = Date.now();
|
||||
var request = new XMLHttpRequest();
|
||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||
request.open('GET', url, true);
|
||||
@@ -175,25 +176,26 @@ function refresh(applyImmediately) {
|
||||
if (typeof getCommandClientId === 'function') {
|
||||
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
|
||||
}
|
||||
if (currentPlaylistEtag) {
|
||||
if (currentPlaylistEtag
|
||||
&& initialData
|
||||
&& Array.isArray(initialData.apiSources)
|
||||
&& Array.isArray(initialData.weatherLocations)) {
|
||||
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
||||
}
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4) {
|
||||
return;
|
||||
}
|
||||
playlistRefreshInFlight = false;
|
||||
if (request.status === 304) {
|
||||
if (!initialData || !Array.isArray(initialData.apiSources) || !Array.isArray(initialData.weatherLocations)) {
|
||||
currentPlaylistEtag = '';
|
||||
refresh(applyImmediately);
|
||||
refresh(applyImmediately, true);
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist refresh completed with no changes.');
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
@@ -249,7 +251,6 @@ function refresh(applyImmediately) {
|
||||
});
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
const currentActiveSlides = getCurrentActiveSlides();
|
||||
if (responseEtag) {
|
||||
currentPlaylistEtag = responseEtag;
|
||||
}
|
||||
@@ -268,9 +269,6 @@ function refresh(applyImmediately) {
|
||||
showCurrent({ skipFade: true });
|
||||
return;
|
||||
}
|
||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
return;
|
||||
}
|
||||
syncWebpagePreloads(nextActiveSlides, index);
|
||||
@@ -287,16 +285,6 @@ function refresh(applyImmediately) {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
return;
|
||||
}
|
||||
@@ -306,11 +294,6 @@ function refresh(applyImmediately) {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
} catch (_error) {
|
||||
logDebug(
|
||||
@@ -323,6 +306,7 @@ function refresh(applyImmediately) {
|
||||
}
|
||||
};
|
||||
request.onerror = function () {
|
||||
playlistRefreshInFlight = false;
|
||||
logDebug(
|
||||
'Unable to load screen playlist.',
|
||||
['URL: ' + url, 'Network error during request.'].join(' | '),
|
||||
@@ -330,11 +314,9 @@ function refresh(applyImmediately) {
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
};
|
||||
request.ontimeout = function () {
|
||||
playlistRefreshInFlight = false;
|
||||
logDebug(
|
||||
'Playlist refresh timed out.',
|
||||
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
|
||||
@@ -342,9 +324,6 @@ function refresh(applyImmediately) {
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
};
|
||||
request.send();
|
||||
}
|
||||
@@ -158,6 +158,148 @@ function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function deferVideoSourcesForPreload(markup) {
|
||||
return String(markup || '').replace(/(<video\b[^>]*?)\s+src=("([^"]*)"|'([^']*)')/gi, function (_match, openingTag, quote, doubleQuoted, singleQuoted) {
|
||||
const source = doubleQuoted !== undefined ? doubleQuoted : singleQuoted;
|
||||
return openingTag + ' data-pulse-video-src=' + quote + source + quote;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSlideMediaPreloads() {
|
||||
mediaPreloadElements.forEach(function (element) {
|
||||
if (!element || element.tagName !== 'VIDEO' || element.parentNode) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.pause === 'function') {
|
||||
element.pause();
|
||||
}
|
||||
if (typeof element.removeAttribute === 'function') {
|
||||
element.removeAttribute('src');
|
||||
}
|
||||
if (typeof element.load === 'function') {
|
||||
element.load();
|
||||
}
|
||||
});
|
||||
mediaPreloadElements = [];
|
||||
mediaPreloadSignature = '';
|
||||
}
|
||||
|
||||
function promotePreloadedVideoElements(root, slideId) {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedSlideId = String(slideId || '');
|
||||
var promotedElements = [];
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.template-region.video video, .slide-media video'), function (activeVideo) {
|
||||
var activeSource = String(activeVideo && (activeVideo.getAttribute('data-pulse-video-src') || activeVideo.getAttribute('src') || activeVideo.src) || '').trim();
|
||||
if (!activeSource || !activeVideo.parentNode) {
|
||||
return;
|
||||
}
|
||||
var absoluteSource = new URL(activeSource, window.location.origin).toString();
|
||||
var preloadedVideo = mediaPreloadElements.find(function (element) {
|
||||
return element && element.tagName === 'VIDEO'
|
||||
&& String(element.__pulsePreloadSlideId || '') === normalizedSlideId
|
||||
&& String(element.src || '').trim() === absoluteSource;
|
||||
});
|
||||
if (!preloadedVideo) {
|
||||
if (activeVideo.getAttribute('data-pulse-video-src')) {
|
||||
activeVideo.removeAttribute('data-pulse-video-src');
|
||||
activeVideo.setAttribute('src', activeSource);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(activeVideo.attributes || [], function (attribute) {
|
||||
var attributeName = String(attribute.name || '').toLowerCase();
|
||||
if (attributeName !== 'src' && attributeName !== 'data-pulse-video-src') {
|
||||
preloadedVideo.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
});
|
||||
preloadedVideo.preload = activeVideo.preload || 'auto';
|
||||
preloadedVideo.muted = Boolean(activeVideo.muted);
|
||||
preloadedVideo.loop = Boolean(activeVideo.loop);
|
||||
preloadedVideo.playsInline = Boolean(activeVideo.playsInline);
|
||||
preloadedVideo.autoplay = Boolean(activeVideo.autoplay);
|
||||
preloadedVideo.removeAttribute('data-pulse-video-src');
|
||||
if (String(preloadedVideo.src || '').trim() !== absoluteSource) {
|
||||
preloadedVideo.src = absoluteSource;
|
||||
}
|
||||
activeVideo.parentNode.replaceChild(preloadedVideo, activeVideo);
|
||||
promotedElements.push(preloadedVideo);
|
||||
});
|
||||
|
||||
if (promotedElements.length) {
|
||||
mediaPreloadElements = mediaPreloadElements.filter(function (element) {
|
||||
return promotedElements.indexOf(element) === -1;
|
||||
});
|
||||
}
|
||||
|
||||
return promotedElements.length;
|
||||
}
|
||||
|
||||
function syncSlideMediaPreloads(sourceSlides, targetIndex) {
|
||||
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (availableSlides.length < 2) {
|
||||
clearSlideMediaPreloads();
|
||||
return;
|
||||
}
|
||||
var currentIndex = Math.min(availableSlides.length - 1, Math.max(0, Number(targetIndex || 0)));
|
||||
var nextSlide = availableSlides[(currentIndex + 1) % availableSlides.length];
|
||||
if (!nextSlide || typeof primeSlideMarkup !== 'function' || typeof document === 'undefined') {
|
||||
clearSlideMediaPreloads();
|
||||
return;
|
||||
}
|
||||
|
||||
var signature = [
|
||||
currentPlaylistSignature || '',
|
||||
nextSlide.id || '',
|
||||
nextSlide.modified_at || '',
|
||||
window.innerWidth + 'x' + window.innerHeight
|
||||
].join('|');
|
||||
if (signature === mediaPreloadSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSlideMediaPreloads();
|
||||
mediaPreloadSignature = signature;
|
||||
|
||||
var holder = document.createElement('div');
|
||||
holder.innerHTML = deferVideoSourcesForPreload(primeSlideMarkup(nextSlide) || '');
|
||||
var mediaUrls = [];
|
||||
Array.prototype.forEach.call(holder.querySelectorAll('img[src], video[data-pulse-video-src]'), function (element) {
|
||||
var isVideo = element.tagName === 'VIDEO';
|
||||
var src = String(element.getAttribute(isVideo ? 'data-pulse-video-src' : 'src') || '').trim();
|
||||
if (!src || mediaUrls.indexOf(src) !== -1) {
|
||||
return;
|
||||
}
|
||||
mediaUrls.push(src);
|
||||
var absoluteUrl = new URL(src, window.location.origin).toString();
|
||||
if (element.tagName === 'VIDEO') {
|
||||
var video = document.createElement('video');
|
||||
Array.prototype.forEach.call(element.attributes || [], function (attribute) {
|
||||
if (String(attribute.name || '').toLowerCase() !== 'src') {
|
||||
video.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
});
|
||||
video.preload = 'auto';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.__pulsePreloadSlideId = String(nextSlide.id || '');
|
||||
video.src = absoluteUrl;
|
||||
if (typeof video.load === 'function') {
|
||||
video.load();
|
||||
}
|
||||
mediaPreloadElements.push(video);
|
||||
return;
|
||||
}
|
||||
var image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.src = absoluteUrl;
|
||||
mediaPreloadElements.push(image);
|
||||
});
|
||||
}
|
||||
|
||||
// Mount hidden iframe preloads for the chosen webpage URLs.
|
||||
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
||||
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
||||
@@ -247,6 +389,7 @@ function savePlaylistSnapshot(data) {
|
||||
slides: Array.isArray(data && data.slides) ? data.slides : [],
|
||||
signature: String(data && data.signature || ''),
|
||||
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
|
||||
skipUnavailableRtmp: Boolean(data && data.skipUnavailableRtmp),
|
||||
etag: String(data && data.etag || ''),
|
||||
savedAt: new Date().toISOString()
|
||||
}));
|
||||
@@ -263,6 +406,7 @@ function applyPlaylistSnapshot(data) {
|
||||
slides = data.slides.map(normalizeSlide);
|
||||
currentPlaylistSignature = String(data.signature || '');
|
||||
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(data.skipUnavailableRtmp);
|
||||
currentPlaylistEtag = String(data.etag || '');
|
||||
pendingPlaylistUpdate = null;
|
||||
clearActiveSlidesCache();
|
||||
@@ -273,3 +417,61 @@ function applyPlaylistSnapshot(data) {
|
||||
index = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPlaybackState() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(playbackStateStorageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
signature: String(parsed.signature || ''),
|
||||
slideId: parsed.slideId === null || parsed.slideId === undefined ? null : String(parsed.slideId),
|
||||
index: Number.isFinite(Number(parsed.index)) ? Number(parsed.index) : 0,
|
||||
paused: Boolean(parsed.paused),
|
||||
blackout: Boolean(parsed.blackout)
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePlaybackState() {
|
||||
try {
|
||||
window.localStorage.setItem(playbackStateStorageKey, JSON.stringify({
|
||||
signature: String(currentPlaylistSignature || ''),
|
||||
slideId: lastRenderedSlide && lastRenderedSlide.id !== undefined ? String(lastRenderedSlide.id) : null,
|
||||
index: Number(index || 0),
|
||||
paused: Boolean(isPaused),
|
||||
blackout: Boolean(isBlackout),
|
||||
savedAt: new Date().toISOString()
|
||||
}));
|
||||
} catch (_error) {
|
||||
// Ignore storage errors so playback remains unaffected.
|
||||
}
|
||||
}
|
||||
|
||||
function restorePlaybackState() {
|
||||
var state = loadPlaybackState();
|
||||
if (!state || state.signature !== String(currentPlaylistSignature || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var restoredIndex = state.index;
|
||||
var matchingSlideIndex = slides.findIndex(function (slide) {
|
||||
return slide && state.slideId !== null && String(slide.id) === state.slideId;
|
||||
});
|
||||
if (matchingSlideIndex >= 0) {
|
||||
restoredIndex = matchingSlideIndex;
|
||||
}
|
||||
if (slides.length) {
|
||||
index = Math.min(Math.max(0, Number(restoredIndex) || 0), slides.length - 1);
|
||||
}
|
||||
isPaused = state.paused;
|
||||
isBlackout = state.blackout;
|
||||
return true;
|
||||
}
|
||||
@@ -1,28 +1,21 @@
|
||||
// General sanitization and sizing helpers.
|
||||
// Strip unsupported characters from a font family string.
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeStyleAttributeValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&quot;/g, '"')
|
||||
.replace(/&#39;/g, "'");
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
var raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
var playerRenderingShared = window.pulsePlayerRenderingShared;
|
||||
var escapeHtml = playerRenderingShared.escapeHtml;
|
||||
var normalizeStyleAttributeValue = playerRenderingShared.normalizeStyleAttributeValue;
|
||||
var sanitizeFontFamily = playerRenderingShared.sanitizeFontFamily;
|
||||
var sanitizeFontSize = playerRenderingShared.sanitizeFontSize;
|
||||
var sanitizeTextColor = playerRenderingShared.sanitizeTextColor;
|
||||
var sanitizeRichTextAttributes = playerRenderingShared.sanitizeRichTextAttributes;
|
||||
var sanitizeRichText = playerRenderingShared.sanitizeRichText;
|
||||
var renderEditorJsBlock = playerRenderingShared.renderEditorJsBlock;
|
||||
var renderEditorJsListItem = playerRenderingShared.renderEditorJsListItem;
|
||||
var renderEditorJsTable = playerRenderingShared.renderEditorJsTable;
|
||||
var renderEditorJsContent = playerRenderingShared.renderEditorJsContent;
|
||||
var parseMaybeJson = playerRenderingShared.parseMaybeJson;
|
||||
var normalizeContentValue = playerRenderingShared.normalizeContentValue;
|
||||
var fitCanvasSize = playerRenderingShared.fitCanvasSize;
|
||||
var decorateRegionMarkup = playerRenderingShared.decorateRegionMarkup;
|
||||
|
||||
function getTemplateCanvasSize(template) {
|
||||
return {
|
||||
@@ -31,6 +24,17 @@ function getTemplateCanvasSize(template) {
|
||||
};
|
||||
}
|
||||
|
||||
function selectPlayerMediaSource(originalSource, variants, targetDimension) {
|
||||
var original = String(originalSource || '').trim();
|
||||
var target = Math.max(1, Number(targetDimension || 0) || 1);
|
||||
var candidates = (Array.isArray(variants) ? variants : []).filter(function (variant) {
|
||||
return variant && String(variant.url || '').trim() && Number(variant.width) >= target;
|
||||
}).sort(function (left, right) {
|
||||
return Number(left.width) - Number(right.width);
|
||||
});
|
||||
return candidates.length ? String(candidates[0].url) : original;
|
||||
}
|
||||
|
||||
function getPlaylistRevision(data) {
|
||||
if (data && data.revision) {
|
||||
return String(data.revision);
|
||||
@@ -44,36 +48,6 @@ function getPlaylistRevision(data) {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
var scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
var preset = String((config[stepName] && config[stepName].preset) || '').trim();
|
||||
return preset && preset !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
@@ -85,264 +59,9 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
// Remove unsafe markup while preserving richer rich-text formatting.
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
// Render a single Editor.js block to HTML.
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
var items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
|
||||
if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
}
|
||||
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render a list item and any nested sub-items.
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
// Render an Editor.js table block.
|
||||
function renderEditorJsTable(data) {
|
||||
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
var hasHeadings = Boolean(data.withHeadings);
|
||||
var tableRows = rows.map(function (row, rowIndex) {
|
||||
var cells = Array.isArray(row) ? row : [];
|
||||
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
// Render Editor.js JSON or plain content safely.
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
var raw = String(value || '');
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
// Parse string values that look like JSON.
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
var raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a slide region's stored content value.
|
||||
function normalizeContentValue(value) {
|
||||
var normalized;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
}
|
||||
if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
}
|
||||
if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
}
|
||||
if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
}
|
||||
if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Normalize a slide and its nested region content.
|
||||
function normalizeSlide(slide) {
|
||||
var normalized = {};
|
||||
var content;
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
var normalized = playerRenderingShared.normalizeSlide(slide);
|
||||
if (normalized.use_video_duration && normalized.template && Array.isArray(normalized.template.regions)) {
|
||||
normalized.template.regions.forEach(function (region) {
|
||||
if (region && region.region_type === 'video' && normalized.content[region.region_key]) {
|
||||
@@ -520,6 +239,8 @@ function getTemplateLayout(template) {
|
||||
baseStyle: baseStyle,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
canvasWidth: templateCanvas.width,
|
||||
canvasHeight: templateCanvas.height,
|
||||
fontFamily: region.font_family || null,
|
||||
fontSize: region.font_size || null,
|
||||
fontColor: region.font_color || null,
|
||||
@@ -530,7 +251,7 @@ function getTemplateLayout(template) {
|
||||
var layout = {
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(templateCanvas.width, templateCanvas.height))) + '" alt="" />' : '',
|
||||
backgroundColor: template.background_color || '#111111',
|
||||
backgroundGradient: template.background_gradient || '',
|
||||
regions: regions
|
||||
@@ -641,7 +362,8 @@ function renderTemplateSlideMarkup(slide) {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path, layout.backgroundGradient) : '';
|
||||
const backgroundPath = layout ? selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(layout.canvasWidth, layout.canvasHeight)) : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', backgroundPath, layout.backgroundGradient) : '';
|
||||
if (layout) {
|
||||
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
// Slide transition timing, cancellation, and outgoing animation coordination.
|
||||
|
||||
var slideOutroTimers = [];
|
||||
var slideTransitionRequestId = 0;
|
||||
var playerWatchdogTimer = null;
|
||||
var playerLastProgressAt = Date.now();
|
||||
var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries';
|
||||
|
||||
function markPlayerPlaybackProgress() {
|
||||
playerLastProgressAt = Date.now();
|
||||
try {
|
||||
window.sessionStorage.removeItem(playerWatchdogRecoveryKey);
|
||||
} catch (_error) {
|
||||
// Session storage may be unavailable in restricted kiosk profiles.
|
||||
}
|
||||
}
|
||||
|
||||
function startPlayerWatchdog() {
|
||||
if (playerWatchdogTimer || typeof window.setInterval !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
playerWatchdogTimer = window.setInterval(function () {
|
||||
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide) {
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedDurationMs = Math.max(10000, Number(getSlideAdvanceDelay(lastRenderedSlide) || 10000));
|
||||
if (Date.now() - playerLastProgressAt <= (expectedDurationMs * 2) + 15000) {
|
||||
return;
|
||||
}
|
||||
|
||||
var recoveryCount = 0;
|
||||
try {
|
||||
recoveryCount = Number(window.sessionStorage.getItem(playerWatchdogRecoveryKey) || 0);
|
||||
} catch (_error) {
|
||||
recoveryCount = 0;
|
||||
}
|
||||
if (recoveryCount >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(playerWatchdogRecoveryKey, String(recoveryCount + 1));
|
||||
} catch (_error) {
|
||||
// Continue with the reload if session storage is unavailable.
|
||||
}
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug('Player watchdog is reloading a stalled playback page.', 'No slide progress detected.', 'error');
|
||||
}
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Cancel the current slide-advance timer.
|
||||
function clearSlideTimer() {
|
||||
@@ -92,13 +142,148 @@ function getSlideHoldDelay(delayMs) {
|
||||
|
||||
// Calculate the configured time from slide entry to the next transition.
|
||||
function getSlideAdvanceDelay(slide) {
|
||||
return getSlideHoldDelay(Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000);
|
||||
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
|
||||
// Video-duration slides start immediately; other slides start midway through the fade.
|
||||
if (slide && slide.use_video_duration) {
|
||||
return currentPlaylistFadeBetweenSlides
|
||||
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
|
||||
: getSlideHoldDelay(durationMs);
|
||||
}
|
||||
if (currentPlaylistFadeBetweenSlides) {
|
||||
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
|
||||
}
|
||||
return getSlideHoldDelay(durationMs);
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
// Cancel fade cleanup and invalidate callbacks already queued for an older transition.
|
||||
function clearSlideTransitionTimer() {
|
||||
slideTransitionRequestId += 1;
|
||||
if (slideTransitionTimer) {
|
||||
window.clearTimeout(slideTransitionTimer);
|
||||
slideTransitionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap slide markup with optional fade animation.
|
||||
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
|
||||
clearSlideTransitionTimer();
|
||||
var transitionRequestId = slideTransitionRequestId;
|
||||
if (typeof deferVideoSourcesForPreload === 'function') {
|
||||
markup = deferVideoSourcesForPreload(markup);
|
||||
}
|
||||
// Stop outgoing region activity before overlapping its shell with the incoming slide.
|
||||
destroyRegionInstances(app);
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app, { preserveVideoFrame: shouldFade });
|
||||
}
|
||||
|
||||
function schedulePostRenderSetup(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeRegionInstances(root);
|
||||
if (typeof initializeSlideMedia === 'function') {
|
||||
initializeSlideMedia(root, delayMs);
|
||||
}
|
||||
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(root, 'intro');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof promotePreloadedVideoElements === 'function') {
|
||||
promotePreloadedVideoElements(app, lastRenderedSlide && lastRenderedSlide.id);
|
||||
}
|
||||
schedulePostRenderSetup(app, 0);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||
var existingShells = topLevelChildren.filter(function (child) {
|
||||
return child && child.classList && child.classList.contains('slide-shell');
|
||||
});
|
||||
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||
|
||||
if (existingShells.length > 1) {
|
||||
// Rapid navigation can interrupt cleanup, leaving older crossfade shells behind.
|
||||
existingShells.slice(0, -1).forEach(function (shell) {
|
||||
if (shell && shell.parentNode) {
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(shell);
|
||||
}
|
||||
shell.parentNode.removeChild(shell);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell slide-shell-entering';
|
||||
nextShell.style.zIndex = '0';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
if (typeof promotePreloadedVideoElements === 'function') {
|
||||
promotePreloadedVideoElements(nextShell, lastRenderedSlide && lastRenderedSlide.id);
|
||||
}
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
if (previousShell && typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app);
|
||||
}
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
app.appendChild(nextShell);
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.classList.remove('is-visible');
|
||||
previousShell.classList.add('is-exiting');
|
||||
previousShell.style.zIndex = '1';
|
||||
previousShell.style.opacity = '1';
|
||||
|
||||
app.appendChild(nextShell);
|
||||
// Commit the initial opacity before starting the crossfade on the next paint frame.
|
||||
window.requestAnimationFrame(function () {
|
||||
if (transitionRequestId !== slideTransitionRequestId) {
|
||||
return;
|
||||
}
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
});
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (transitionRequestId !== slideTransitionRequestId) {
|
||||
return;
|
||||
}
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(previousShell);
|
||||
}
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.zIndex = '1';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeLengthMs);
|
||||
|
||||
return nextShell;
|
||||
}
|
||||
@@ -32,3 +32,38 @@
|
||||
list: list
|
||||
};
|
||||
}());
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
module[lifecycleName](root);
|
||||
} catch (_error) {
|
||||
// Keep slide rendering resilient if a region lifecycle hook fails.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'destroyRegion');
|
||||
}
|
||||
|
||||
function initializeRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'initRegion');
|
||||
}
|
||||
+25
-2
@@ -5,6 +5,7 @@ const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
|
||||
const PLAYER_CACHE_PREFIX = 'pulse-signage-player-';
|
||||
|
||||
function normalizeRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
@@ -34,8 +35,13 @@ async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
|
||||
if (!response || !response.ok) {
|
||||
return;
|
||||
}
|
||||
// Cache storage is best-effort; quota or storage failures must not discard a live response.
|
||||
try {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(cacheKeyRequest || request, response.clone());
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName, cacheKeyRequest) {
|
||||
@@ -114,10 +120,22 @@ self.addEventListener('install', function (event) {
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil((async function () {
|
||||
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
|
||||
const keys = await caches.keys();
|
||||
const playerCacheVersions = keys.filter(function (key) {
|
||||
return key.startsWith(PLAYER_CACHE_PREFIX);
|
||||
}).map(function (key) {
|
||||
return key.slice(PLAYER_CACHE_PREFIX.length).replace(/^(?:pages|assets|media|playlists)-/, '');
|
||||
}).filter(Boolean);
|
||||
const previousVersion = playerCacheVersions.reverse().find(function (version) {
|
||||
return version !== CACHE_VERSION;
|
||||
});
|
||||
const retainedVersions = new Set([CACHE_VERSION, previousVersion].filter(Boolean));
|
||||
await Promise.all(keys.filter(function (key) {
|
||||
return expected.indexOf(key) === -1;
|
||||
if (!key.startsWith(PLAYER_CACHE_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const version = key.slice(PLAYER_CACHE_PREFIX.length).replace(/^(?:pages|assets|media|playlists)-/, '');
|
||||
return !retainedVersions.has(version);
|
||||
}).map(function (key) {
|
||||
return caches.delete(key);
|
||||
}));
|
||||
@@ -145,6 +163,11 @@ self.addEventListener('fetch', function (event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/player-script/')) {
|
||||
event.respondWith(networkFirst(request, ASSET_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||
return;
|
||||
|
||||
@@ -112,28 +112,8 @@ function getApiPreviewFallback(item) {
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderApiRegion(region, regionContent) {
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var content = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
|
||||
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : '';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Chart region rendering and lifecycle management.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
var chartInstances = [];
|
||||
|
||||
function getChartData(regionContent) {
|
||||
var sources = Array.isArray(window.initialData && window.initialData.apiSources) ? window.initialData.apiSources : [];
|
||||
return window.pulseChartData.fromContent(regionContent || {}, sources);
|
||||
}
|
||||
|
||||
function renderChartRegion(region, regionContent) {
|
||||
var canvasId = 'player-chart-' + String(region && (region.regionKey || region.label) || '').replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
var chartJson = escapeHtml(JSON.stringify(regionContent || {}));
|
||||
return '<div class="template-region chart" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas id="' + canvasId + '" data-chart-json="' + chartJson + '" aria-label="Chart" style="width:100%;height:100%;display:block;background:transparent;"></canvas></div>';
|
||||
}
|
||||
|
||||
function initChartRegions(root) {
|
||||
if (typeof window.Chart !== 'function' || !window.pulseChartData || !root || !root.querySelectorAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.querySelectorAll('.template-region.chart canvas').forEach(function (canvas) {
|
||||
var region = canvas.parentElement;
|
||||
var regionKey = region && region.getAttribute('data-region-key');
|
||||
var content = {};
|
||||
try {
|
||||
content = JSON.parse(canvas.getAttribute('data-chart-json') || '{}');
|
||||
} catch (_error) {
|
||||
content = {};
|
||||
}
|
||||
var chartData = getChartData(content);
|
||||
var chart = new window.Chart(canvas.getContext('2d'), {
|
||||
type: chartData.type,
|
||||
data: { labels: chartData.labels, datasets: chartData.datasets },
|
||||
options: window.pulseChartData.getChartOptions(chartData)
|
||||
});
|
||||
chartInstances.push({ canvas: canvas, chart: chart, regionKey: regionKey });
|
||||
});
|
||||
}
|
||||
|
||||
function destroyChartRegions(root) {
|
||||
chartInstances = chartInstances.filter(function (entry) {
|
||||
if (!entry || !entry.canvas || !root || !root.contains(entry.canvas)) {
|
||||
return true;
|
||||
}
|
||||
entry.chart.destroy();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('chart', {
|
||||
renderRegion: renderChartRegion,
|
||||
initRegion: initChartRegions,
|
||||
destroyRegion: destroyChartRegions
|
||||
});
|
||||
@@ -2,46 +2,13 @@
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return normalizeRenderableValue(value.value);
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return normalizeRenderableValue(value.text);
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return normalizeRenderableValue(value.html);
|
||||
}
|
||||
if (value.content !== undefined) {
|
||||
return normalizeRenderableValue(value.content);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function buildHtmlDocument(html) {
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}html,body{background:transparent !important;}</style></head><body>' + raw + '</body></html>';
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
var html = normalizeRenderableValue(value).trim();
|
||||
var html = window.pulsePlayerRenderingShared.normalizeRenderableString(value, ['value', 'text', 'html', 'content']).trim();
|
||||
if (!html) {
|
||||
return '';
|
||||
}
|
||||
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
|
||||
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(window.pulsePlayerRenderingShared.buildHtmlDocument(html, true)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
return '<div class="template-region-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderImageRegion(region, regionContent) {
|
||||
var src = regionContent.value || '';
|
||||
var src = selectPlayerMediaSource(regionContent.value || '', regionContent.media_variants, Math.max(region.canvasWidth || 0, region.canvasHeight || 0));
|
||||
if (!String(src || '').trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -11,26 +11,6 @@ function getDefaultStyle() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getRssFeedById(feedId) {
|
||||
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
|
||||
var normalizedId = Number(feedId || 0);
|
||||
@@ -99,7 +79,7 @@ function substituteRssVariables(html, item, feedId) {
|
||||
}
|
||||
|
||||
function renderRssRegion(region, regionContent) {
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var content = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
|
||||
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||
var defaultStyle = getDefaultStyle();
|
||||
|
||||
@@ -10,28 +10,8 @@ function getDefaultStyle() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderTextRegion(region, regionContent) {
|
||||
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var rawValue = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
|
||||
if (!String(rawValue || '').trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Video region playback, retry, and source probe helpers.
|
||||
|
||||
// These page-lifetime caches retain fallback sources while mirrored replacements are probed.
|
||||
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||
var videoRegionProbeStateCache = Object.create(null);
|
||||
var videoRegionProbeTimerCache = Object.create(null);
|
||||
@@ -10,6 +11,7 @@ function getVideoRegionCacheKey(region) {
|
||||
}
|
||||
|
||||
function isDirectlyRenderableSource(src) {
|
||||
// URL-shaped sources go directly to the browser; this bypass is not a readiness check.
|
||||
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Regions sharing a source also share its pending probe, avoiding duplicate HEAD requests.
|
||||
if (videoRegionProbeTimerCache[key]) {
|
||||
return;
|
||||
}
|
||||
@@ -106,7 +109,7 @@ function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||
}
|
||||
|
||||
function renderVideoRegion(region, regionContent) {
|
||||
var requestedSrc = String(regionContent && regionContent.value || '').trim();
|
||||
var requestedSrc = selectPlayerMediaSource(regionContent && regionContent.value || '', regionContent && regionContent.media_variants, Math.max(region.canvasWidth || 0, region.canvasHeight || 0));
|
||||
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
|
||||
var regionKey = getVideoRegionCacheKey(region);
|
||||
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||
@@ -139,6 +142,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
|
||||
// Keep the previous source visible while the requested mirrored file is unavailable.
|
||||
if (cachedSrc) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
|
||||
@@ -2,34 +2,8 @@
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return normalizeRenderableValue(value.value);
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return normalizeRenderableValue(value.text);
|
||||
}
|
||||
if (value.url !== undefined) {
|
||||
return normalizeRenderableValue(value.url);
|
||||
}
|
||||
if (value.href !== undefined) {
|
||||
return normalizeRenderableValue(value.href);
|
||||
}
|
||||
if (value.src !== undefined) {
|
||||
return normalizeRenderableValue(value.src);
|
||||
}
|
||||
if (value.content !== undefined) {
|
||||
return normalizeRenderableValue(value.content);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function renderWebpageRegion(region, regionContent) {
|
||||
var url = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '').trim();
|
||||
var url = window.pulsePlayerRenderingShared.normalizeRenderableString(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'url', 'href', 'src', 'content']).trim();
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
+23
-338
@@ -3,6 +3,25 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
const renderingShared = require('#src/player/rendering-shared');
|
||||
const {
|
||||
escapeHtml,
|
||||
sanitizeRichTextAttributes,
|
||||
sanitizeRichText,
|
||||
renderEditorJsBlock,
|
||||
renderEditorJsListItem,
|
||||
renderEditorJsTable,
|
||||
renderEditorJsContent,
|
||||
normalizeRenderableString,
|
||||
buildHtmlDocument,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor,
|
||||
parseMaybeJson,
|
||||
normalizeContentValue,
|
||||
normalizeSlide,
|
||||
fitCanvasSize
|
||||
} = renderingShared;
|
||||
|
||||
function mediaKind(mediaPath) {
|
||||
const ext = path.extname(mediaPath || '').toLowerCase();
|
||||
@@ -18,331 +37,18 @@ function mediaKind(mediaPath) {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeStyleAttributeValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&quot;/g, '"')
|
||||
.replace(/&#39;/g, "'");
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function safeJsonForScript(value) {
|
||||
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw[0] !== '{' && raw[0] !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentValue(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
} else if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSlide(slide) {
|
||||
const normalized = {};
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
const attrText = String(match[3] || '');
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
} else if (block.type === 'list') {
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
||||
} else if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
} else if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
} else if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
} else if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderEditorJsTable(data) {
|
||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
const hasHeadings = Boolean(data.withHeadings);
|
||||
const tableRows = rows.map(function (row, rowIndex) {
|
||||
const cells = Array.isArray(row) ? row : [];
|
||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
const raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
const raw = String(value || '');
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
function buildHtmlDocument(html) {
|
||||
const raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + raw + '</body></html>';
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
const html = normalizeRenderableValue(value).trim();
|
||||
const html = normalizeRenderableString(value, ['value', 'text', 'html', 'content']).trim();
|
||||
if (!html) {
|
||||
return '<div class="template-region-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return normalizeRenderableValue(value.value);
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return normalizeRenderableValue(value.text);
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return normalizeRenderableValue(value.html);
|
||||
}
|
||||
if (value.content !== undefined) {
|
||||
return normalizeRenderableValue(value.content);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
|
||||
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
|
||||
@@ -359,6 +65,7 @@ const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announceme
|
||||
const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html');
|
||||
const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js');
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
const playerRenderingSharedScriptPath = path.join(__dirname, 'rendering-shared.js');
|
||||
const playerRegionScriptDir = path.join(__dirname, 'regions');
|
||||
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
|
||||
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
|
||||
@@ -373,7 +80,6 @@ let playerAnnouncementTemplatesScriptCache = null;
|
||||
let playerAnnouncementTemplatesDataScriptCache = null;
|
||||
let playerPageAnnouncementsScriptCache = null;
|
||||
let playerPageScriptCache = null;
|
||||
let playerRegionScriptsCache = null;
|
||||
let playerOnboardingLandingScriptCache = null;
|
||||
let playerOnboardingFormScriptCache = null;
|
||||
|
||||
@@ -498,28 +204,6 @@ function getPlayerRegionScriptPaths() {
|
||||
});
|
||||
}
|
||||
|
||||
function getPlayerRegionScripts() {
|
||||
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
|
||||
const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js');
|
||||
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
|
||||
const statSignature = scriptPaths.map(function (filePath) {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
}).join('|');
|
||||
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
|
||||
return playerRegionScriptsCache.value;
|
||||
}
|
||||
|
||||
const value = scriptPaths.map(function (filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
}).join('\n\n');
|
||||
playerRegionScriptsCache = {
|
||||
signature: statSignature,
|
||||
value: value
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPlayerOnboardingLandingScript() {
|
||||
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
|
||||
}
|
||||
@@ -539,7 +223,9 @@ function getPlayerRuntimeScripts() {
|
||||
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()],
|
||||
['chart-data', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'regions', 'chart-data.js'), 'utf8').trim()],
|
||||
['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()],
|
||||
['rendering-shared', fs.readFileSync(playerRenderingSharedScriptPath, 'utf8').trim()],
|
||||
['client-name', getScriptBody(getPlayerClientNameScript()())],
|
||||
['offline', getScriptBody(getPlayerPageOfflineScript()())],
|
||||
['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
|
||||
@@ -588,7 +274,6 @@ module.exports = {
|
||||
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
|
||||
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
|
||||
getPlayerPageScript: getPlayerPageScript,
|
||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript,
|
||||
getPlayerRuntimeScripts: getPlayerRuntimeScripts
|
||||
|
||||
+12
-16
@@ -1,9 +1,10 @@
|
||||
// Player page rendering and bootstrap script assembly.
|
||||
|
||||
const Handlebars = require('handlebars');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const packageMetadata = require('../../package.json');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
|
||||
const packageMetadata = require('#root/package.json');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('#src/player/render-helpers');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
@@ -100,20 +101,11 @@ function renderOnboardingFormBody(deviceId) {
|
||||
}
|
||||
|
||||
|
||||
function renderOnboardingLandingScript() {
|
||||
return getPlayerOnboardingLandingScript()();
|
||||
}
|
||||
|
||||
function renderOnboardingFormScript(deviceId) {
|
||||
return getPlayerOnboardingFormScript()({
|
||||
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerPage(slug, initialData) {
|
||||
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||
const template = getPlayerPageTemplate();
|
||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
const bootstrapScript = getPlayerPageScript()({
|
||||
@@ -122,7 +114,9 @@ function renderPlayerPage(slug, initialData) {
|
||||
REGION_SCRIPTS: ''
|
||||
});
|
||||
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
|
||||
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js?v=' + encodeURIComponent(String(packageMetadata.version || 'development')) + '"></script>';
|
||||
const scriptRevision = crypto.createHash('sha256').update(entry[1]).digest('hex').slice(0, 12);
|
||||
const assetVersion = String(packageMetadata.version || 'development') + '-' + scriptRevision;
|
||||
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js?v=' + encodeURIComponent(assetVersion) + '"></script>';
|
||||
}).join('');
|
||||
|
||||
return renderPage(template, {
|
||||
@@ -130,7 +124,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
bodyClass: '',
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + chartScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,7 +136,7 @@ function renderPlayerOnboardingLandingPage(options) {
|
||||
bodyClass: 'onboarding-page',
|
||||
body: renderOnboardingLandingBody(options),
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + getPlayerOnboardingLandingScript()()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +148,9 @@ function renderPlayerOnboardingFormPage(deviceId) {
|
||||
bodyClass: 'onboarding-page',
|
||||
body: renderOnboardingFormBody(deviceId),
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + getPlayerOnboardingFormScript()({
|
||||
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
(function (root, factory) {
|
||||
const shared = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = shared;
|
||||
} else if (root) {
|
||||
root.pulsePlayerRenderingShared = shared;
|
||||
}
|
||||
}(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
const allowedTags = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeStyleAttributeValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&quot;/g, '"')
|
||||
.replace(/&#39;/g, "'");
|
||||
}
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target' && String(value || '').trim() === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
const attrText = String(match[3] || '');
|
||||
if (!allowedTags.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderEditorJsTable(data) {
|
||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
const hasHeadings = Boolean(data.withHeadings);
|
||||
const tableRows = rows.map(function (row, rowIndex) {
|
||||
const cells = Array.isArray(row) ? row : [];
|
||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
if (block.type === 'list') {
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
}
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
const raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
const raw = String(value || '');
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// Continue with legacy HTML content when the value is not Editor.js JSON.
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
function normalizeRenderableString(value, propertyKeys) {
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Array.isArray(propertyKeys) ? propertyKeys : ['value', 'text', 'html', 'content'];
|
||||
for (const key of keys) {
|
||||
if (value[key] !== undefined) {
|
||||
return normalizeRenderableString(value[key], keys);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function normalizeRegionValue(value, propertyKeys) {
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Array.isArray(propertyKeys) ? propertyKeys : ['value', 'text', 'html'];
|
||||
for (const key of keys) {
|
||||
if (value[key] !== undefined) {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(html, forceTransparentBackground) {
|
||||
const raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const transparencyRule = forceTransparentBackground ? 'html,body{background:transparent !important;}' : '';
|
||||
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}' + transparencyRule + '</style></head><body>' + raw + '</body></html>';
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw || (raw[0] !== '{' && raw[0] !== '[')) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentValue(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
}
|
||||
if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
}
|
||||
if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
}
|
||||
if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
}
|
||||
if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSlide(slide) {
|
||||
const normalized = {};
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
const preset = String((config[stepName] && config[stepName].preset) || '').trim();
|
||||
return preset && preset !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
const animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (_match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
escapeHtml: escapeHtml,
|
||||
normalizeStyleAttributeValue: normalizeStyleAttributeValue,
|
||||
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||
sanitizeRichText: sanitizeRichText,
|
||||
renderEditorJsBlock: renderEditorJsBlock,
|
||||
renderEditorJsListItem: renderEditorJsListItem,
|
||||
renderEditorJsTable: renderEditorJsTable,
|
||||
renderEditorJsContent: renderEditorJsContent,
|
||||
normalizeRenderableString: normalizeRenderableString,
|
||||
normalizeRegionValue: normalizeRegionValue,
|
||||
buildHtmlDocument: buildHtmlDocument,
|
||||
sanitizeFontFamily: sanitizeFontFamily,
|
||||
sanitizeFontSize: sanitizeFontSize,
|
||||
sanitizeTextColor: sanitizeTextColor,
|
||||
parseMaybeJson: parseMaybeJson,
|
||||
normalizeContentValue: normalizeContentValue,
|
||||
normalizeSlide: normalizeSlide,
|
||||
fitCanvasSize: fitCanvasSize,
|
||||
hasPlayerAnimation: hasPlayerAnimation,
|
||||
decorateRegionMarkup: decorateRegionMarkup
|
||||
};
|
||||
}));
|
||||
+10
-2
@@ -3,8 +3,9 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { getPlayerPublicBaseUrl } = require('./onboarding');
|
||||
const { getPlayerPublicBaseUrl } = require('#src/player/onboarding/index');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
|
||||
@@ -269,6 +270,7 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
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/vendor/chart.js', express.static(path.join(__dirname, '..', 'web', 'public', 'vendor', 'chart.js')));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||
|
||||
@@ -336,7 +338,13 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
const temporaryFilePath = filePath + '.partial-' + crypto.randomUUID();
|
||||
try {
|
||||
await fs.promises.writeFile(temporaryFilePath, body);
|
||||
await fs.promises.rename(temporaryFilePath, filePath);
|
||||
} finally {
|
||||
await fs.promises.unlink(temporaryFilePath).catch(function () {});
|
||||
}
|
||||
res.json({ ok: true, filename: req.params.filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
+6
-6
@@ -5,25 +5,25 @@ const http = require('http');
|
||||
const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const common = require('./common');
|
||||
const common = require('#src/common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
|
||||
const pages = require('#src/web/pages');
|
||||
const pages = require('#src/web/pages/index');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const registerNotFoundHandler = require('#src/web/middleware/not-found');
|
||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks/index');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
const { captureSlideThumbnail } = require('#src/web/lib/media');
|
||||
const { captureSlideThumbnail } = require('#src/web/lib/media/index');
|
||||
const { createWebBootstrap } = require('#src/web/bootstrap');
|
||||
const { registerRoutes } = require('#src/web/routes/register');
|
||||
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
|
||||
const { initializeWebServer } = require('#src/web/lib/web-startup');
|
||||
const { createWebConfig } = require('#src/web/lib/config');
|
||||
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { rbacData } = require('#src/web/lib/auth');
|
||||
const { rbacData } = require('#src/web/lib/auth/index');
|
||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth');
|
||||
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { sendAccountEmail } = require('#src/data/mailer');
|
||||
const { recordRequestAuditEvent } = require('#src/data/audit-log');
|
||||
|
||||
Vendored
+2
-2
@@ -1,8 +1,8 @@
|
||||
// Web-to-player websocket bridge and dashboard state synchronizer.
|
||||
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/media');
|
||||
const { createDashboardStateService } = require('#src/web/lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('#src/web/lib/media/index');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Entry point for the web auth helper modules.
|
||||
|
||||
module.exports = {
|
||||
createSessionService: require('./session').createSessionService,
|
||||
getRequestOrigin: require('./session').getRequestOrigin,
|
||||
rbacData: require('./rbac-data')
|
||||
createSessionService: require('#src/web/lib/auth/session').createSessionService,
|
||||
getRequestOrigin: require('#src/web/lib/auth/session').getRequestOrigin,
|
||||
rbacData: require('#src/web/lib/auth/rbac-data')
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
|
||||
@@ -8,8 +8,8 @@ const TASK = {
|
||||
intervalMs: null
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
|
||||
@@ -11,7 +11,7 @@ const TASK = {
|
||||
const {
|
||||
collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations
|
||||
} = require('../../media/font-library');
|
||||
} = require('#src/web/lib/media/font-library');
|
||||
|
||||
function registerFontSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { collectLocalControlUsers } = require('../../local-control-users');
|
||||
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
|
||||
|
||||
const TASK = {
|
||||
key: 'player-control-sync',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
|
||||
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
||||
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
|
||||
|
||||
const TASK = {
|
||||
key: 'initial-font-sync',
|
||||
category: 'fonts'
|
||||
};
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
// Queue a one-off font sync for each live player on startup. The task keeps the resolved player
|
||||
// metadata in the payload so retries and background workers do not need to rediscover the same URL.
|
||||
|
||||
function normalizePlayerMetadata(player) {
|
||||
const playerIdentifier = String(player && player.identifier || '').trim();
|
||||
|
||||
@@ -3,15 +3,11 @@ const TASK = {
|
||||
category: 'media-sync',
|
||||
};
|
||||
|
||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||
// Queue a one-off media sync for each live player when the web app starts up. We intentionally
|
||||
// do the target selection here rather than inside the task itself so the task payload remains
|
||||
// explicit and reusable for later retries.
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
|
||||
|
||||
function normalizePlayerMetadata(player) {
|
||||
const playerIdentifier = String(player && player.identifier || '').trim();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Helpers for normalizing dashboard state and date values.
|
||||
|
||||
const { WebSocket } = require('ws');
|
||||
const { isRecentPlayerRegistration } = require('#src/data/player-registry');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -52,14 +53,8 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
||||
});
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
// Only build launcher entries for recent registrations with a public URL. This keeps the kiosk
|
||||
// dashboard list focused on active devices and avoids presenting stale players as launch targets.
|
||||
function buildKioskLauncherPlayers(playerRegistrations, staleSeconds) {
|
||||
return (Array.isArray(playerRegistrations) ? playerRegistrations : [])
|
||||
.filter(function (player) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Media helper entry point for upload sync and thumbnail capture.
|
||||
|
||||
module.exports = {
|
||||
createUploadSyncService: require('./upload-sync').createUploadSyncService,
|
||||
createUploadSyncService: require('#src/web/lib/media/upload-sync').createUploadSyncService,
|
||||
captureSlideThumbnail: function captureSlideThumbnail(options) {
|
||||
return require('./slide-thumbnails').captureSlideThumbnail(options);
|
||||
return require('#src/web/lib/media/slide-thumbnails').captureSlideThumbnail(options);
|
||||
},
|
||||
fontLibrary: require('./font-library')
|
||||
fontLibrary: require('#src/web/lib/media/font-library')
|
||||
};
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const MEDIA_MIME_TYPES = {
|
||||
'.gif': 'image/gif',
|
||||
@@ -17,6 +19,121 @@ const MEDIA_MIME_TYPES = {
|
||||
'.webm': 'video/webm'
|
||||
};
|
||||
|
||||
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
|
||||
|
||||
function getImageVariantPath(filePath, dimension) {
|
||||
const extension = path.extname(filePath);
|
||||
const baseName = path.basename(filePath, extension);
|
||||
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
|
||||
}
|
||||
|
||||
async function removeImageVariants(filePath) {
|
||||
const normalizedPath = String(filePath || '').trim();
|
||||
const extension = path.extname(normalizedPath).toLowerCase();
|
||||
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
|
||||
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function createImageVariants(file) {
|
||||
const filePath = String(file && file.path || '').trim();
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxDimension = Math.max.apply(null, IMAGE_VARIANT_DIMENSIONS);
|
||||
const originalStats = await fs.promises.stat(filePath);
|
||||
const metadata = await sharp(filePath, { failOn: 'none' }).metadata();
|
||||
const sourceDimension = Math.max(Number(metadata.width) || 0, Number(metadata.height) || 0);
|
||||
const variants = [];
|
||||
|
||||
for (const dimension of IMAGE_VARIANT_DIMENSIONS) {
|
||||
if (dimension > maxDimension || dimension >= sourceDimension) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformer = sharp(filePath, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({ width: dimension, height: dimension, fit: 'inside', withoutEnlargement: true });
|
||||
if (extension === '.jpg' || extension === '.jpeg') {
|
||||
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
|
||||
} else if (extension === '.png') {
|
||||
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
|
||||
} else {
|
||||
transformer.webp({ quality: 82, effort: 5 });
|
||||
}
|
||||
|
||||
const variantBuffer = await transformer.toBuffer();
|
||||
if (!variantBuffer.length || variantBuffer.length >= originalStats.size) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const variantPath = getImageVariantPath(filePath, dimension);
|
||||
const temporaryPath = variantPath + '.partial-' + crypto.randomUUID();
|
||||
try {
|
||||
await fs.promises.writeFile(temporaryPath, variantBuffer);
|
||||
await fs.promises.rename(temporaryPath, variantPath);
|
||||
variants.push({
|
||||
width: dimension,
|
||||
path: '/media/uploads/' + path.basename(variantPath),
|
||||
fileSize: variantBuffer.length
|
||||
});
|
||||
} finally {
|
||||
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
async function optimizeUploadedImage(file, options) {
|
||||
const filePath = String(file && file.path || '').trim();
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const requestedMaxDimension = Number(options && options.maxDimension);
|
||||
const maxDimension = Number.isInteger(requestedMaxDimension) && requestedMaxDimension > 0
|
||||
? requestedMaxDimension
|
||||
: 4096;
|
||||
const originalStats = await fs.promises.stat(filePath);
|
||||
const transformer = sharp(filePath, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true });
|
||||
if (extension === '.jpg' || extension === '.jpeg') {
|
||||
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
|
||||
} else if (extension === '.png') {
|
||||
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
|
||||
} else {
|
||||
transformer.webp({ quality: 82, effort: 5 });
|
||||
}
|
||||
|
||||
const optimizedBuffer = await transformer.toBuffer();
|
||||
if (!optimizedBuffer.length || optimizedBuffer.length >= originalStats.size) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const temporaryPath = filePath + '.optimized-' + crypto.randomUUID();
|
||||
try {
|
||||
await fs.promises.writeFile(temporaryPath, optimizedBuffer);
|
||||
await fs.promises.rename(temporaryPath, filePath);
|
||||
file.size = optimizedBuffer.length;
|
||||
return file;
|
||||
} finally {
|
||||
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMediaPath(value) {
|
||||
const mediaPath = String(value || '').trim();
|
||||
return mediaPath.startsWith('/media/') ? mediaPath : null;
|
||||
@@ -103,6 +220,9 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
|
||||
continue;
|
||||
}
|
||||
const originalName = String(entry.name || '').trim();
|
||||
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) {
|
||||
continue;
|
||||
}
|
||||
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
|
||||
if (!mimeType) {
|
||||
continue;
|
||||
@@ -163,11 +283,15 @@ async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
await removeImageVariants(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeMediaPath,
|
||||
createImageVariants,
|
||||
removeImageVariants,
|
||||
optimizeUploadedImage,
|
||||
registerMediaAsset,
|
||||
registerMediaAssets,
|
||||
fetchMediaAssets,
|
||||
|
||||
@@ -156,6 +156,10 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
||||
}
|
||||
|
||||
if (regionType === 'chart') {
|
||||
return '<div class="slide-preview-region slide-preview-chart-region" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas data-chart-json="' + escapeHtml(JSON.stringify(regionContent || {})) + '" aria-label="' + escapeHtml(region.label || region.region_key || 'chart') + '"></canvas></div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
@@ -188,6 +192,7 @@ function buildThumbnailPreviewPayload(slide, options) {
|
||||
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
||||
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
|
||||
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
||||
apiSources: Array.isArray(options && options.apiSources) ? options.apiSources : [],
|
||||
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
||||
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
|
||||
};
|
||||
|
||||
@@ -1,53 +1,29 @@
|
||||
// Upload sync helpers for mirroring web media to player storage.
|
||||
// This service owns the upload queue and deferred player-target selection, while the shared
|
||||
// player registry helper owns the repeated URL/heartbeat resolution rules used elsewhere.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { collectFontLibrarySyncOperations } = require('./font-library');
|
||||
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
||||
const {
|
||||
fetchPlayerRegistrations,
|
||||
getConfiguredPlayerIdentifier,
|
||||
isLocalLikeBaseUrl,
|
||||
isRecentPlayerRegistration,
|
||||
normalizeBaseUrl
|
||||
} = require('#src/data/player-registry');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function isLocalLikeBaseUrl(value) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return host === 'localhost'
|
||||
|| host === '127.0.0.1'
|
||||
|| host === '::1'
|
||||
|| host === 'host.docker.internal'
|
||||
|| host === 'player'
|
||||
|| host === 'web'
|
||||
|| host === 'player-bridge'
|
||||
|| host.endsWith('.local')
|
||||
|| host.endsWith('.internal')
|
||||
|| host.endsWith('.docker.internal');
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normalizePlayerRowBaseUrl(player) {
|
||||
return normalizeBaseUrl(player && player.internal_base_url);
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
@@ -297,6 +273,30 @@ function createUploadSyncService(options) {
|
||||
return path.join(mediaRoot, relativePath);
|
||||
}
|
||||
|
||||
async function getUploadPathsWithVariants(uploadPath, uploadDir) {
|
||||
const normalizedPath = normalizeUploadReference(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(uploadDir, normalizedPath);
|
||||
if (!normalizedPath || !sourcePath || !/\.(?:jpe?g|png|webp)$/i.test(normalizedPath)) {
|
||||
return normalizedPath ? [normalizedPath] : [];
|
||||
}
|
||||
|
||||
const extension = path.extname(sourcePath);
|
||||
const baseName = path.basename(sourcePath, extension);
|
||||
const directory = path.dirname(sourcePath);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||
} catch (_error) {
|
||||
return [normalizedPath];
|
||||
}
|
||||
|
||||
const variantPattern = new RegExp('^' + baseName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '\\.variant-\\d+' + extension.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '$', 'i');
|
||||
return [normalizedPath].concat(entries
|
||||
.filter(function (entry) { return entry && entry.isFile() && variantPattern.test(entry.name); })
|
||||
.map(function (entry) { return normalizedPath.replace(/[^/]+$/, entry.name); })
|
||||
.sort());
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromValue(value, refs) {
|
||||
if (!value) {
|
||||
return refs;
|
||||
@@ -584,8 +584,8 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
async function removeSingleUploadFileFromPlayer(uploadPath, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
|
||||
if (!uploadPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -632,6 +632,17 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
|
||||
const uploadPaths = await getUploadPathsWithVariants(uploadPath, localUploadDir);
|
||||
let success = true;
|
||||
for (let index = 0; index < uploadPaths.length; index += 1) {
|
||||
if (!await removeSingleUploadFileFromPlayer(uploadPaths[index], resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
|
||||
if (!shouldMirrorUploads(localUploadDir)) {
|
||||
return;
|
||||
@@ -640,11 +651,14 @@ function createUploadSyncService(options) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
|
||||
const uploadPaths = await getUploadPathsWithVariants(uniqueRefs[i], localUploadDir);
|
||||
for (let pathIndex = 0; pathIndex < uploadPaths.length; pathIndex += 1) {
|
||||
const uploadPath = uploadPaths[pathIndex];
|
||||
const success = await pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uniqueRefs[i],
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: localUploadDir,
|
||||
playerIdentifier: preferredPlayerIdentifier,
|
||||
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
|
||||
@@ -656,6 +670,7 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleCurrentSlideIds() {
|
||||
const visibleSlideIds = new Set();
|
||||
@@ -938,14 +953,17 @@ function createUploadSyncService(options) {
|
||||
}).forEach(function (uploadPath) {
|
||||
uploadRefs.add(uploadPath);
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
for (const uploadPath of uploadRefs) {
|
||||
const uploadPaths = await getUploadPathsWithVariants(uploadPath, uploadDir);
|
||||
uploadPaths.forEach(function (variantPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uploadPath,
|
||||
uploadPath: variantPath,
|
||||
uploadDir: uploadDir,
|
||||
metadata: playerMetadata
|
||||
});
|
||||
});
|
||||
}
|
||||
fontLibraryOperations.forEach(function (operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
// Commands and synchronization actions forwarded from the web app to players.
|
||||
// Keep the routing layer separate from the actual player device commands so the web app can
|
||||
// resolve a target player URL without mixing in connection or device-specific logic.
|
||||
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
|
||||
function isLocalLikeBaseUrl(value) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return host === 'localhost'
|
||||
|| host === '127.0.0.1'
|
||||
|| host === '::1'
|
||||
|| host === 'host.docker.internal'
|
||||
|| host === 'player'
|
||||
|| host === 'web'
|
||||
|| host === 'player-bridge'
|
||||
|| host.endsWith('.local')
|
||||
|| host.endsWith('.internal')
|
||||
|| host.endsWith('.docker.internal');
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
const {
|
||||
getConfiguredPlayerIdentifier,
|
||||
isLocalLikeBaseUrl,
|
||||
isRecentPlayerRegistration,
|
||||
normalizeBaseUrl
|
||||
} = require('#src/data/player-registry');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const pool = options && options.pool;
|
||||
|
||||
@@ -6,7 +6,7 @@ const path = require('path');
|
||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js', 'js/regions/chart-data.js', 'vendor/chart.js/chart.umd.js'];
|
||||
|
||||
function withAssetVersion(scriptPath, assetVersion) {
|
||||
if (!assetVersion) {
|
||||
|
||||
@@ -58,7 +58,7 @@ module.exports = {
|
||||
renderMediaLibraryPage: require(routePath('settings', 'media-library', 'list')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
renderErrorPage: require('#src/web/pages/error'),
|
||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
|
||||
|
||||
@@ -162,12 +162,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform .2s ease;
|
||||
}
|
||||
|
||||
.media-gallery-preview:hover img,
|
||||
.media-gallery-preview:hover video {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.media-gallery-preview:focus-visible {
|
||||
|
||||
@@ -204,6 +204,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
function queueUploadCleanup(input, uploadPath) {
|
||||
var path = String(uploadPath || '').trim();
|
||||
var cleanupUrl = String(input && input.dataset.uploadCleanupUrl || '').trim();
|
||||
if (!path || !cleanupUrl) {
|
||||
return;
|
||||
}
|
||||
var payload = JSON.stringify({ uploadPaths: [path] });
|
||||
if (navigator.sendBeacon) {
|
||||
try {
|
||||
navigator.sendBeacon(cleanupUrl, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
} catch (_error) {
|
||||
// Fall through to fetch when sendBeacon is unavailable.
|
||||
}
|
||||
}
|
||||
fetch(cleanupUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: payload,
|
||||
credentials: 'same-origin',
|
||||
keepalive: true
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
async function uploadSelectedFile(input, file) {
|
||||
var uploadUrl = String(input.dataset.uploadUrl || '').trim();
|
||||
var hidden = document.getElementById(String(input.dataset.uploadHidden || '').trim());
|
||||
if (!uploadUrl || !hidden || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
var maxBytes = Number(input.dataset.imageMaxBytes || 0);
|
||||
if (maxBytes > 0 && file.size > maxBytes) {
|
||||
input.value = '';
|
||||
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', file, file.name || 'upload');
|
||||
input.setCustomValidity('Upload is still in progress.');
|
||||
try {
|
||||
var response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
var payload = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok || !payload.path) {
|
||||
throw new Error(payload.error || 'Unable to upload media.');
|
||||
}
|
||||
var previousPath = String(input.dataset.uploadedPath || '').trim();
|
||||
if (previousPath && previousPath !== payload.path) {
|
||||
queueUploadCleanup(input, previousPath);
|
||||
}
|
||||
input.dataset.uploadedPath = payload.path;
|
||||
input.dataset.uploadedNeedsCleanup = '1';
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
hidden.value = payload.path;
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
updatePreview(hidden, payload.path, 'image');
|
||||
} catch (error) {
|
||||
input.value = '';
|
||||
var currentPath = String(hidden.value || '').trim();
|
||||
var backgroundPreview = document.getElementById('background-preview');
|
||||
var backgroundEmpty = document.getElementById('background-empty');
|
||||
if (currentPath) {
|
||||
updatePreview(hidden, currentPath, 'image');
|
||||
} else if (backgroundPreview) {
|
||||
backgroundPreview.removeAttribute('src');
|
||||
backgroundPreview.style.display = 'none';
|
||||
if (backgroundEmpty) backgroundEmpty.style.display = 'flex';
|
||||
}
|
||||
input.setCustomValidity('');
|
||||
window.PulseUpload.showMessage(error.message || 'Unable to upload media.');
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var uploadTrigger = event.target && event.target.closest ? event.target.closest('[data-media-picker-upload]') : null;
|
||||
if (uploadTrigger) {
|
||||
@@ -247,8 +328,46 @@
|
||||
updateSelection();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var removeBackground = event.target && event.target.closest ? event.target.closest('#remove-background-image') : null;
|
||||
if (!removeBackground) {
|
||||
return;
|
||||
}
|
||||
var input = document.getElementById('background-image');
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1') {
|
||||
queueUploadCleanup(input, input.dataset.uploadedPath);
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
delete input.dataset.uploadedPath;
|
||||
}
|
||||
});
|
||||
|
||||
if (search) search.addEventListener('input', resetVisibleItemLimit);
|
||||
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
|
||||
document.addEventListener('change', function (event) {
|
||||
var input = event.target;
|
||||
if (!input || input.type !== 'file' || !input.dataset) {
|
||||
return;
|
||||
}
|
||||
var file = input.files && input.files[0];
|
||||
if (input.dataset.uploadUrl) {
|
||||
input.setCustomValidity('');
|
||||
if (file) {
|
||||
uploadSelectedFile(input, file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.dataset.uploadMaxBytes) {
|
||||
return;
|
||||
}
|
||||
var maxBytes = Number(input.dataset.uploadMaxBytes);
|
||||
if (!file || !Number.isFinite(maxBytes) || maxBytes <= 0 || file.size <= maxBytes) {
|
||||
input.setCustomValidity('');
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
input.setCustomValidity('Image must be ' + Math.round(maxBytes / 1024 / 1024) + ' MB or smaller.');
|
||||
input.reportValidity();
|
||||
});
|
||||
if (loadMoreButton) loadMoreButton.addEventListener('click', async function () {
|
||||
loadMoreButton.disabled = true;
|
||||
try {
|
||||
@@ -274,12 +393,31 @@
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
var input = getActiveFileInput();
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && input.dataset.uploadedPath !== pendingPath) {
|
||||
queueUploadCleanup(input, input.dataset.uploadedPath);
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
delete input.dataset.uploadedPath;
|
||||
}
|
||||
hidden.value = pendingPath;
|
||||
updatePreview(hidden, pendingPath, pendingType);
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
closePicker();
|
||||
});
|
||||
if (cancelButton) cancelButton.addEventListener('click', closePicker);
|
||||
document.querySelectorAll('input[type="file"][data-upload-url]').forEach(function (input) {
|
||||
var form = input.form;
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
});
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', function () {
|
||||
document.querySelectorAll('input[type="file"][data-upload-url][data-uploaded-needs-cleanup="1"]').forEach(function (input) {
|
||||
queueUploadCleanup(input, input.dataset.uploadedPath);
|
||||
});
|
||||
});
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
activeButton = null;
|
||||
currentPath = '';
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Normalize chart configurations and source snapshots for editor previews and player playback.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var allowedTypes = ['line', 'bar', 'doughnut', 'pie'];
|
||||
|
||||
function getPath(value, path) {
|
||||
var current = value;
|
||||
var parts = String(path || '').trim().replace(/\[([0-9]+)\]/g, '.$1').split('.').filter(Boolean);
|
||||
for (var index = 0; index < parts.length; index += 1) {
|
||||
if (current === undefined || current === null) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[parts[index]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
var normalized = String(value === undefined || value === null ? '' : value).replace(/,/g, '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
var parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeChartType(value) {
|
||||
var type = String(value || 'bar').trim().toLowerCase();
|
||||
return allowedTypes.indexOf(type) !== -1 ? type : 'bar';
|
||||
}
|
||||
|
||||
function getApiItems(response, itemsPath) {
|
||||
var selected = itemsPath ? getPath(response, itemsPath) : response;
|
||||
if (Array.isArray(selected)) {
|
||||
return selected;
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
if (response && Array.isArray(response.items)) return response.items;
|
||||
if (response && Array.isArray(response.results)) return response.results;
|
||||
if (response && Array.isArray(response.data)) return response.data;
|
||||
return response && typeof response === 'object' ? [response] : [];
|
||||
}
|
||||
|
||||
function normalizeRows(rows, mapping) {
|
||||
var labels = [];
|
||||
var values = [];
|
||||
var labelPath = String(mapping && (mapping.label || mapping.label_path) || 'label').trim();
|
||||
var valuePath = String(mapping && (mapping.value || mapping.value_path) || 'value').trim();
|
||||
(Array.isArray(rows) ? rows : []).forEach(function (row, index) {
|
||||
var label = getPath(row, labelPath);
|
||||
var value = toNumber(getPath(row, valuePath));
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
labels.push(label === undefined || label === null || label === '' ? String(index + 1) : String(label));
|
||||
values.push(value);
|
||||
});
|
||||
return { labels: labels, values: values };
|
||||
}
|
||||
|
||||
function normalizeConfig(content) {
|
||||
var current = content && typeof content === 'object' ? content : {};
|
||||
var mapping = current.mapping && typeof current.mapping === 'object' ? current.mapping : {};
|
||||
var rawDatasets = Array.isArray(current.datasets) ? current.datasets : [];
|
||||
var datasets = rawDatasets.slice(0, 12).map(function (dataset, index) {
|
||||
var item = dataset && typeof dataset === 'object' ? dataset : {};
|
||||
return {
|
||||
label: String(item.label || 'Series ' + (index + 1)).trim() || 'Series ' + (index + 1),
|
||||
value_path: String(item.value_path || item.value || '').trim(),
|
||||
color: /^#[0-9a-fA-F]{3,8}$/.test(String(item.color || '').trim()) ? String(item.color).trim() : ''
|
||||
};
|
||||
}).filter(function (dataset) { return dataset.value_path; });
|
||||
var palette = Array.isArray(current.colors)
|
||||
? current.colors
|
||||
: String(current.color_palette || '').split(',');
|
||||
palette = palette.map(function (color) {
|
||||
return String(color || '').trim();
|
||||
}).filter(function (color) {
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(color);
|
||||
}).slice(0, 24);
|
||||
var fallbackColor = /^#[0-9a-fA-F]{3,8}$/.test(String(current.color || '').trim()) ? String(current.color).trim() : '#36a269';
|
||||
var chartOptions = current.chart_options && typeof current.chart_options === 'object' ? current.chart_options : {};
|
||||
var sourceOptions = current.source_options && typeof current.source_options === 'object' ? current.source_options : {};
|
||||
return {
|
||||
type: normalizeChartType(current.chart_type || current.chartType),
|
||||
source_kind: String(current.source_kind || current.sourceKind || 'manual').trim().toLowerCase() === 'api' ? 'api' : 'manual',
|
||||
source_id: current.source_id === undefined || current.source_id === '' ? '' : Number(current.source_id),
|
||||
items_path: String(current.items_path || '').trim(),
|
||||
label_path: String(current.label_path || mapping.label || '').trim(),
|
||||
value_path: String(current.value_path || mapping.value || '').trim(),
|
||||
dataset_label: String(current.dataset_label || 'Value').trim() || 'Value',
|
||||
color: fallbackColor,
|
||||
datasets: datasets,
|
||||
colors: palette.length ? palette : [fallbackColor, '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6'],
|
||||
manual_rows: Array.isArray(current.manual_rows) ? current.manual_rows : [],
|
||||
chart_options: {
|
||||
legend: ['automatic', 'show', 'hide'].indexOf(String(chartOptions.legend || 'automatic')) !== -1 ? String(chartOptions.legend || 'automatic') : 'automatic',
|
||||
begin_at_zero: chartOptions.begin_at_zero !== false,
|
||||
y_axis_min: chartOptions.y_axis_min === undefined || chartOptions.y_axis_min === '' ? '' : Number(chartOptions.y_axis_min),
|
||||
y_axis_max: chartOptions.y_axis_max === undefined || chartOptions.y_axis_max === '' ? '' : Number(chartOptions.y_axis_max),
|
||||
line_points: ['automatic', 'show', 'hide'].indexOf(String(chartOptions.line_points || 'automatic')) !== -1 ? String(chartOptions.line_points || 'automatic') : 'automatic',
|
||||
line_fill: chartOptions.line_fill === true,
|
||||
bar_orientation: chartOptions.bar_orientation === 'horizontal' ? 'horizontal' : 'vertical',
|
||||
value_format: ['number', 'percent', 'currency'].indexOf(String(chartOptions.value_format || 'number')) !== -1 ? String(chartOptions.value_format || 'number') : 'number',
|
||||
decimal_places: Math.max(0, Math.min(4, Number(chartOptions.decimal_places) || 0)),
|
||||
text_size: Math.max(8, Math.min(48, Number(chartOptions.text_size) || 12)),
|
||||
grid_color: /^#[0-9a-fA-F]{6}$/.test(String(chartOptions.grid_color || '').trim()) ? String(chartOptions.grid_color).trim() : '#000000',
|
||||
text_color: /^#[0-9a-fA-F]{6}$/.test(String(chartOptions.text_color || '').trim()) ? String(chartOptions.text_color).trim() : '#000000'
|
||||
},
|
||||
source_options: {
|
||||
sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].indexOf(String(sourceOptions.sort || 'none')) !== -1 ? String(sourceOptions.sort || 'none') : 'none',
|
||||
limit: Math.max(0, Math.min(500, Number(sourceOptions.limit) || 0)),
|
||||
missing: ['skip', 'zero', 'gap'].indexOf(String(sourceOptions.missing || 'gap')) !== -1 ? String(sourceOptions.missing || 'gap') : 'gap'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isColor(value) {
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function getSeries(config) {
|
||||
if (config.type === 'line' && config.datasets.length) {
|
||||
return config.datasets;
|
||||
}
|
||||
var firstDataset = config.datasets[0] || {};
|
||||
return [{
|
||||
label: config.dataset_label,
|
||||
value_path: config.value_path || firstDataset.value_path || 'value',
|
||||
color: config.color || firstDataset.color
|
||||
}];
|
||||
}
|
||||
|
||||
function normalizeSeries(rows, config) {
|
||||
var series = getSeries(config);
|
||||
var labels = [];
|
||||
var values = series.map(function () { return []; });
|
||||
var colors = [];
|
||||
var sourceOptions = config.source_options || {};
|
||||
var sourceRows = (Array.isArray(rows) ? rows : []).slice();
|
||||
if (sourceOptions.sort !== 'none' && sourceOptions.sort !== 'reverse-original') {
|
||||
sourceRows.sort(function (left, right) {
|
||||
var firstSeries = series[0] || {};
|
||||
var leftValue = toNumber(getPath(left, firstSeries.value_path));
|
||||
var rightValue = toNumber(getPath(right, firstSeries.value_path));
|
||||
if (leftValue === null) return 1;
|
||||
if (rightValue === null) return -1;
|
||||
return (leftValue - rightValue) * (sourceOptions.sort === 'value-descending' ? -1 : 1);
|
||||
});
|
||||
}
|
||||
if (sourceOptions.sort === 'reverse-original') sourceRows.reverse();
|
||||
if (sourceOptions.limit > 0) sourceRows = sourceRows.slice(0, sourceOptions.limit);
|
||||
sourceRows.forEach(function (row, rowIndex) {
|
||||
var rowValues = series.map(function (dataset) {
|
||||
return toNumber(getPath(row, dataset.value_path));
|
||||
});
|
||||
if (!rowValues.some(function (value) { return value !== null; })) {
|
||||
return;
|
||||
}
|
||||
if (sourceOptions.missing === 'skip' && rowValues.some(function (value) { return value === null; })) {
|
||||
return;
|
||||
}
|
||||
if (sourceOptions.missing === 'zero') {
|
||||
rowValues = rowValues.map(function (value) { return value === null ? 0 : value; });
|
||||
}
|
||||
var label = getPath(row, config.label_path || 'label');
|
||||
labels.push(label === undefined || label === null || label === '' ? String(rowIndex + 1) : String(label));
|
||||
rowValues.forEach(function (value, datasetIndex) {
|
||||
values[datasetIndex].push(value);
|
||||
});
|
||||
colors.push(isColor(row && row.color) ? String(row.color).trim() : config.colors[rowIndex % config.colors.length]);
|
||||
});
|
||||
return { labels: labels, values: values, colors: colors };
|
||||
}
|
||||
|
||||
function fromContent(content, sources) {
|
||||
var config = normalizeConfig(content);
|
||||
var rows = config.manual_rows;
|
||||
if (config.source_kind === 'api') {
|
||||
var source = (Array.isArray(sources) ? sources : []).find(function (entry) {
|
||||
return Number(entry.id) === Number(config.source_id);
|
||||
});
|
||||
rows = getApiItems(source && source.responseJson, config.items_path || source && (source.items_path || source.itemsPath) || '');
|
||||
}
|
||||
var normalizedRows = normalizeSeries(rows, config);
|
||||
var series = getSeries(config);
|
||||
var linePointRadius = config.chart_options.line_points === 'show' ? 3 : config.chart_options.line_points === 'hide' ? 0 : undefined;
|
||||
return {
|
||||
type: config.type,
|
||||
options: { chart_options: config.chart_options },
|
||||
labels: normalizedRows.labels,
|
||||
datasets: series.map(function (dataset, index) {
|
||||
var color = dataset.color || config.colors[index % config.colors.length];
|
||||
var pointColors = config.type === 'bar' || config.type === 'pie' || config.type === 'doughnut' ? normalizedRows.colors : null;
|
||||
return {
|
||||
label: dataset.label,
|
||||
data: normalizedRows.values[index],
|
||||
backgroundColor: pointColors || color,
|
||||
borderColor: pointColors || color,
|
||||
borderWidth: 2,
|
||||
tension: 0.25,
|
||||
fill: config.type === 'line' && config.chart_options.line_fill,
|
||||
pointRadius: linePointRadius
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function getChartOptions(chartData) {
|
||||
var chartOptions = chartData.options && chartData.options.chart_options || {};
|
||||
var textSize = Math.max(8, Math.min(48, Number(chartOptions.text_size) || 12));
|
||||
var legendDefault = chartData.type !== 'bar' && (chartData.type !== 'line' || (chartData.datasets || []).length > 1);
|
||||
var legendDisplay = chartOptions.legend === 'show' ? true : chartOptions.legend === 'hide' ? false : legendDefault;
|
||||
var scales = {};
|
||||
var ticks = { color: chartOptions.text_color || '#000000', font: { size: textSize } };
|
||||
if (chartData.type !== 'pie' && chartData.type !== 'doughnut') {
|
||||
var valueScale = { beginAtZero: chartOptions.begin_at_zero !== false, grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
|
||||
if (chartOptions.y_axis_min !== '') valueScale.min = chartOptions.y_axis_min;
|
||||
if (chartOptions.y_axis_max !== '') valueScale.max = chartOptions.y_axis_max;
|
||||
scales[chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal' ? 'x' : 'y'] = valueScale;
|
||||
if (chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal') scales.y = { beginAtZero: true, grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
|
||||
if (!scales.x) scales.x = { grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
|
||||
if (!scales.y) scales.y = { grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
|
||||
}
|
||||
var tooltipLabel = function (context) {
|
||||
var value = context.parsed && (context.parsed.y === undefined ? context.parsed : context.parsed.y);
|
||||
var decimals = Number(chartOptions.decimal_places) || 0;
|
||||
if (chartOptions.value_format === 'percent') return Number(value).toFixed(decimals) + '%';
|
||||
if (chartOptions.value_format === 'currency') return '$' + Number(value).toFixed(decimals);
|
||||
return Number(value).toLocaleString(undefined, { maximumFractionDigits: decimals });
|
||||
};
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
indexAxis: chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal' ? 'y' : 'x',
|
||||
color: chartOptions.text_color || '#000000',
|
||||
font: { size: textSize },
|
||||
plugins: { legend: { display: legendDisplay, labels: { color: chartOptions.text_color || '#000000', font: { size: textSize } } }, tooltip: { titleColor: chartOptions.text_color || '#000000', bodyColor: chartOptions.text_color || '#000000', footerColor: chartOptions.text_color || '#000000', titleFont: { size: textSize }, bodyFont: { size: textSize }, footerFont: { size: textSize }, callbacks: { label: tooltipLabel } } },
|
||||
scales: scales
|
||||
};
|
||||
}
|
||||
|
||||
root.pulseChartData = {
|
||||
getPath: getPath,
|
||||
normalizeConfig: normalizeConfig,
|
||||
normalizeRows: normalizeRows,
|
||||
fromContent: fromContent,
|
||||
getChartOptions: getChartOptions
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,271 @@
|
||||
// Chart region editor and preview helpers.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var chartData = window.pulseChartData;
|
||||
var defaultSeriesColors = ['#36a269', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6'];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.pulseRegionUtils && window.pulseRegionUtils.escapeHtml
|
||||
? window.pulseRegionUtils.escapeHtml(value)
|
||||
: String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function parseRows(value) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(value || '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentConfig(region, existingContent) {
|
||||
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
var config = chartData.normalizeConfig(current);
|
||||
config.manual_rows_json = JSON.stringify(config.manual_rows, null, 2);
|
||||
config.datasets_json = JSON.stringify(config.datasets, null, 2);
|
||||
return config;
|
||||
}
|
||||
|
||||
function renderPreview(region, content, context) {
|
||||
var normalized = chartData.fromContent(content || {}, context && context.sources || []);
|
||||
var chartId = 'chart-preview-' + region.id;
|
||||
var label = escapeHtml((content && content.dataset_label) || 'Value');
|
||||
return '<div class="template-region chart" style="width:100%;height:100%;overflow:hidden;position:relative;">' +
|
||||
'<canvas id="' + chartId + '" data-chart-json="' + escapeHtml(JSON.stringify(normalized)) + '" aria-label="' + label + ' chart"></canvas>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function initPreview(root) {
|
||||
if (typeof window.Chart !== 'function' || !root || !root.querySelectorAll) {
|
||||
return;
|
||||
}
|
||||
root.querySelectorAll('.template-region.chart canvas').forEach(function (canvas) {
|
||||
var data = {};
|
||||
try { data = JSON.parse(canvas.getAttribute('data-chart-json') || '{}'); } catch (_error) { data = {}; }
|
||||
new window.Chart(canvas.getContext('2d'), {
|
||||
type: data.type || 'bar',
|
||||
data: { labels: data.labels || [], datasets: data.datasets || [] },
|
||||
options: chartData.getChartOptions(data)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncSourceFields(card) {
|
||||
if (!card || !card.querySelector) {
|
||||
return;
|
||||
}
|
||||
var sourceKind = card.querySelector('[name^="region_chart_source_kind_"]');
|
||||
var isApi = sourceKind && sourceKind.value === 'api';
|
||||
card.querySelectorAll('[data-chart-source-panel="api"], [data-chart-source-panel="manual"]').forEach(function (panel) {
|
||||
var visible = panel.getAttribute('data-chart-source-panel') === (isApi ? 'api' : 'manual');
|
||||
panel.hidden = !visible;
|
||||
panel.querySelectorAll('input, select, textarea').forEach(function (input) {
|
||||
input.disabled = !visible;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDatasetRow(dataset, index, regionId) {
|
||||
var current = dataset || {};
|
||||
var hasColor = /^#[0-9a-fA-F]{3,8}$/.test(String(current.color || '').trim());
|
||||
return '<div class="row g-2 align-items-end mb-2" data-chart-dataset-row>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_dataset_label_' + regionId + '_' + index + '">Series label</label><input id="region_chart_dataset_label_' + regionId + '_' + index + '" class="form-control" data-chart-dataset-label value="' + escapeHtml(current.label || 'Series ' + (index + 1)) + '" /></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_dataset_value_' + regionId + '_' + index + '">Value field</label><input id="region_chart_dataset_value_' + regionId + '_' + index + '" class="form-control" data-chart-dataset-value value="' + escapeHtml(current.value_path || '') + '" placeholder="sales" /></div>' +
|
||||
'<div class="col-10 col-md-3"><label class="form-label" for="region_chart_dataset_color_' + regionId + '_' + index + '">Series color</label><input id="region_chart_dataset_color_' + regionId + '_' + index + '" class="form-control form-control-color w-100" data-chart-dataset-color data-chart-dataset-color-explicit="' + (hasColor ? 'true' : 'false') + '" type="color" value="' + escapeHtml(hasColor ? current.color : defaultSeriesColors[index % defaultSeriesColors.length]) + '" /></div>' +
|
||||
'<div class="col-2 col-md-1"><button type="button" class="btn btn-outline-danger w-100" data-chart-dataset-remove aria-label="Remove series" title="Remove series">×</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function bindEditorCard(context) {
|
||||
var card = context && context.card;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
var sourceKind = card.querySelector('[name^="region_chart_source_kind_"]');
|
||||
var chartType = card.querySelector('[name^="region_chart_type_"]');
|
||||
var datasetEditor = card.querySelector('[data-chart-dataset-editor]');
|
||||
var singleValueFields = card.querySelectorAll('[data-chart-single-value-field]');
|
||||
if (sourceKind) {
|
||||
sourceKind.addEventListener('change', function () {
|
||||
syncSourceFields(card);
|
||||
if (context.requestPreviewRender) context.requestPreviewRender();
|
||||
});
|
||||
}
|
||||
function syncDatasetEditor() {
|
||||
var usesDatasets = chartType && chartType.value === 'line';
|
||||
if (datasetEditor) {
|
||||
datasetEditor.hidden = !usesDatasets;
|
||||
}
|
||||
singleValueFields.forEach(function (field) {
|
||||
field.hidden = usesDatasets;
|
||||
});
|
||||
card.querySelectorAll('[data-chart-option-types]').forEach(function (field) {
|
||||
var types = String(field.getAttribute('data-chart-option-types') || '').split(' ');
|
||||
var hidden = !chartType || types.indexOf(chartType.value) === -1;
|
||||
field.hidden = hidden;
|
||||
field.classList.toggle('d-none', hidden);
|
||||
});
|
||||
}
|
||||
if (chartType) {
|
||||
chartType.addEventListener('change', function () {
|
||||
syncDatasetEditor();
|
||||
if (context.requestPreviewRender) context.requestPreviewRender();
|
||||
});
|
||||
}
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var datasetList = card.querySelector('[data-chart-datasets]');
|
||||
var datasetInput = card.querySelector('[name="region_chart_datasets_' + regionId + '"]');
|
||||
var addDatasetButton = card.querySelector('[data-chart-dataset-add]');
|
||||
function syncDatasets() {
|
||||
var datasets = [];
|
||||
card.querySelectorAll('[data-chart-dataset-row]').forEach(function (row) {
|
||||
var label = row.querySelector('[data-chart-dataset-label]');
|
||||
var valuePath = row.querySelector('[data-chart-dataset-value]');
|
||||
var color = row.querySelector('[data-chart-dataset-color]');
|
||||
var explicitColor = color && color.getAttribute('data-chart-dataset-color-explicit') === 'true';
|
||||
if (valuePath && valuePath.value.trim()) {
|
||||
datasets.push({ label: label && label.value.trim() || 'Series ' + (datasets.length + 1), value_path: valuePath.value.trim(), color: explicitColor ? color.value : '' });
|
||||
}
|
||||
});
|
||||
if (datasetInput) datasetInput.value = JSON.stringify(datasets);
|
||||
if (context.requestPreviewRender) context.requestPreviewRender();
|
||||
}
|
||||
if (addDatasetButton && datasetList) {
|
||||
addDatasetButton.addEventListener('click', function () {
|
||||
var index = datasetList.querySelectorAll('[data-chart-dataset-row]').length;
|
||||
datasetList.insertAdjacentHTML('beforeend', renderDatasetRow({ label: 'Series ' + (index + 1), value_path: '' }, index, regionId));
|
||||
syncDatasets();
|
||||
});
|
||||
}
|
||||
if (datasetList) {
|
||||
datasetList.addEventListener('input', function (event) {
|
||||
if (event.target.matches('[data-chart-dataset-color]')) {
|
||||
event.target.setAttribute('data-chart-dataset-color-explicit', 'true');
|
||||
}
|
||||
syncDatasets();
|
||||
});
|
||||
datasetList.addEventListener('change', syncDatasets);
|
||||
datasetList.addEventListener('click', function (event) {
|
||||
var removeButton = event.target.closest('[data-chart-dataset-remove]');
|
||||
if (!removeButton) return;
|
||||
var rows = datasetList.querySelectorAll('[data-chart-dataset-row]');
|
||||
if (rows.length > 1) removeButton.closest('[data-chart-dataset-row]').remove();
|
||||
syncDatasets();
|
||||
});
|
||||
syncDatasets();
|
||||
}
|
||||
syncSourceFields(card);
|
||||
syncDatasetEditor();
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var chartOptions = config.chart_options || {};
|
||||
var sourceOptions = config.source_options || {};
|
||||
var datasets = config.datasets && config.datasets.length ? config.datasets : [{ label: config.dataset_label || 'Value', value_path: config.value_path || 'value', color: '' }];
|
||||
var datasetRows = datasets.map(function (dataset, index) { return renderDatasetRow(dataset, index, region.id); }).join('');
|
||||
var accordionId = 'region-chart-options-' + region.id;
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head"><strong>' + escapeHtml(region.label) + '</strong><div class="template-field-actions"><span class="chip">Chart</span></div></div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="row g-3">' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_type_' + region.id + '">Chart type</label><select id="region_chart_type_' + region.id + '" name="region_chart_type_' + region.id + '" class="form-select"><option value="bar"' + (config.type === 'bar' ? ' selected' : '') + '>Bar</option><option value="line"' + (config.type === 'line' ? ' selected' : '') + '>Line</option><option value="doughnut"' + (config.type === 'doughnut' ? ' selected' : '') + '>Doughnut</option><option value="pie"' + (config.type === 'pie' ? ' selected' : '') + '>Pie</option></select></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_source_kind_' + region.id + '">Data source</label><select id="region_chart_source_kind_' + region.id + '" name="region_chart_source_kind_' + region.id + '" class="form-select"><option value="manual"' + (config.source_kind === 'manual' ? ' selected' : '') + '>Manual rows</option><option value="api"' + (config.source_kind === 'api' ? ' selected' : '') + '>API source</option></select></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-source-panel="api"><label class="form-label" for="region_chart_source_id_' + region.id + '">API source</label><select id="region_chart_source_id_' + region.id + '" name="region_chart_source_id_' + region.id + '" class="form-select"><option value="">Select a source</option>' + String(context.sourceOptions || '') + '</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3" data-chart-source-panel="api">' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_items_path_' + region.id + '">Items path</label><input id="region_chart_items_path_' + region.id + '" name="region_chart_items_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.items_path) + '" placeholder="results" /></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-single-value-field><label class="form-label" for="region_chart_label_path_' + region.id + '">Label field</label><input id="region_chart_label_path_' + region.id + '" name="region_chart_label_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.label_path) + '" placeholder="date" /></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-single-value-field><label class="form-label" for="region_chart_value_path_' + region.id + '">Single value field</label><input id="region_chart_value_path_' + region.id + '" name="region_chart_value_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.value_path) + '" placeholder="value" /></div>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3">' +
|
||||
'<div class="col-12" data-chart-source-panel="manual"><label class="form-label" for="region_chart_manual_rows_' + region.id + '">Manual rows (JSON)</label><textarea id="region_chart_manual_rows_' + region.id + '" name="region_chart_manual_rows_' + region.id + '" class="form-control" rows="5">' + escapeHtml(config.manual_rows_json) + '</textarea><div class="form-text">Rows use a label and one or more mapped values, for example [{"label":"Mon","value":12,"color":"#36a269"}].</div></div>' +
|
||||
'<div class="col-12" data-chart-dataset-editor' + (config.type === 'line' ? '' : ' hidden') + '><div class="d-flex align-items-center justify-content-between gap-2"><label class="form-label mb-0">Data series</label><button type="button" class="btn btn-outline-secondary btn-sm" data-chart-dataset-add><i class="bi bi-plus-lg" aria-hidden="true"></i> Add series</button></div><div class="mt-2" data-chart-datasets>' + datasetRows + '</div><input type="hidden" name="region_chart_datasets_' + region.id + '" value="' + escapeHtml(JSON.stringify(datasets)) + '" /><div class="form-text">Add one section per API field or manual value. Set the color for each series here.</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="accordion" id="' + accordionId + '">' +
|
||||
'<div class="accordion-item overflow-hidden"><h2 class="accordion-header" id="' + accordionId + '-heading-chart"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#' + accordionId + '-chart" aria-expanded="false" aria-controls="' + accordionId + '-chart">Chart options</button></h2><div id="' + accordionId + '-chart" class="accordion-collapse collapse" aria-labelledby="' + accordionId + '-heading-chart" data-bs-parent="#' + accordionId + '"><div class="accordion-body"><div class="row g-3">' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_legend_' + region.id + '">Legend</label><select id="region_chart_legend_' + region.id + '" name="region_chart_legend_' + region.id + '" class="form-select"><option value="automatic"' + (chartOptions.legend === 'automatic' ? ' selected' : '') + '>Automatic</option><option value="show"' + (chartOptions.legend === 'show' ? ' selected' : '') + '>Show</option><option value="hide"' + (chartOptions.legend === 'hide' ? ' selected' : '') + '>Hide</option></select></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_value_format_' + region.id + '">Value format</label><select id="region_chart_value_format_' + region.id + '" name="region_chart_value_format_' + region.id + '" class="form-select"><option value="number"' + (chartOptions.value_format === 'number' ? ' selected' : '') + '>Number</option><option value="percent"' + (chartOptions.value_format === 'percent' ? ' selected' : '') + '>Percent</option><option value="currency"' + (chartOptions.value_format === 'currency' ? ' selected' : '') + '>Currency</option></select></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_decimal_places_' + region.id + '">Decimal places</label><input id="region_chart_decimal_places_' + region.id + '" name="region_chart_decimal_places_' + region.id + '" class="form-control" type="number" min="0" max="4" value="' + escapeHtml(chartOptions.decimal_places === undefined ? 0 : chartOptions.decimal_places) + '" /></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_text_size_' + region.id + '">Text size (px)</label><input id="region_chart_text_size_' + region.id + '" name="region_chart_text_size_' + region.id + '" class="form-control" type="number" min="8" max="48" step="1" value="' + escapeHtml(chartOptions.text_size || 12) + '" /></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_grid_color_' + region.id + '">Grid line color</label><input id="region_chart_grid_color_' + region.id + '" name="region_chart_grid_color_' + region.id + '" class="form-control form-control-color" type="color" value="' + escapeHtml(chartOptions.grid_color || '#000000') + '" title="Choose grid line color" /></div>' +
|
||||
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_text_color_' + region.id + '">Text color</label><input id="region_chart_text_color_' + region.id + '" name="region_chart_text_color_' + region.id + '" class="form-control form-control-color" type="color" value="' + escapeHtml(chartOptions.text_color || '#000000') + '" title="Choose chart text color" /></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-option-types="line bar"><label class="form-label" for="region_chart_y_axis_min_' + region.id + '">Y-axis minimum</label><input id="region_chart_y_axis_min_' + region.id + '" name="region_chart_y_axis_min_' + region.id + '" class="form-control" type="number" value="' + escapeHtml(chartOptions.y_axis_min) + '" placeholder="Automatic" /></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-option-types="line bar"><label class="form-label" for="region_chart_y_axis_max_' + region.id + '">Y-axis maximum</label><input id="region_chart_y_axis_max_' + region.id + '" name="region_chart_y_axis_max_' + region.id + '" class="form-control" type="number" value="' + escapeHtml(chartOptions.y_axis_max) + '" placeholder="Automatic" /></div>' +
|
||||
'<div class="col-12 col-md-4 d-flex align-items-end" data-chart-option-types="line bar"><div class="form-check mb-2"><input type="hidden" name="region_chart_begin_at_zero_control_' + region.id + '" value="1" /><input id="region_chart_begin_at_zero_' + region.id + '" name="region_chart_begin_at_zero_' + region.id + '" class="form-check-input" type="checkbox"' + (chartOptions.begin_at_zero !== false ? ' checked' : '') + ' /><label class="form-check-label" for="region_chart_begin_at_zero_' + region.id + '">Start Y-axis at zero</label></div></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-option-types="line"><label class="form-label" for="region_chart_line_points_' + region.id + '">Line points</label><select id="region_chart_line_points_' + region.id + '" name="region_chart_line_points_' + region.id + '" class="form-select"><option value="automatic"' + (chartOptions.line_points === 'automatic' ? ' selected' : '') + '>Automatic</option><option value="show"' + (chartOptions.line_points === 'show' ? ' selected' : '') + '>Show</option><option value="hide"' + (chartOptions.line_points === 'hide' ? ' selected' : '') + '>Hide</option></select></div>' +
|
||||
'<div class="col-12 col-md-4" data-chart-option-types="bar"><label class="form-label" for="region_chart_bar_orientation_' + region.id + '">Bar orientation</label><select id="region_chart_bar_orientation_' + region.id + '" name="region_chart_bar_orientation_' + region.id + '" class="form-select"><option value="vertical"' + (chartOptions.bar_orientation !== 'horizontal' ? ' selected' : '') + '>Vertical</option><option value="horizontal"' + (chartOptions.bar_orientation === 'horizontal' ? ' selected' : '') + '>Horizontal</option></select></div>' +
|
||||
'<div class="col-12 col-md-4 d-flex align-items-end" data-chart-option-types="line"><div class="form-check mb-2"><input id="region_chart_line_fill_' + region.id + '" name="region_chart_line_fill_' + region.id + '" class="form-check-input" type="checkbox"' + (chartOptions.line_fill ? ' checked' : '') + ' /><label class="form-check-label" for="region_chart_line_fill_' + region.id + '">Fill line area</label></div></div>' +
|
||||
'</div></div></div></div>' +
|
||||
'<div class="accordion-item overflow-hidden"><h2 class="accordion-header" id="' + accordionId + '-heading-source"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#' + accordionId + '-source" aria-expanded="false" aria-controls="' + accordionId + '-source">Source options</button></h2><div id="' + accordionId + '-source" class="accordion-collapse collapse" aria-labelledby="' + accordionId + '-heading-source" data-bs-parent="#' + accordionId + '"><div class="accordion-body"><div class="row g-3">' +
|
||||
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_sort_' + region.id + '">Order</label><select id="region_chart_source_sort_' + region.id + '" name="region_chart_source_sort_' + region.id + '" class="form-select"><option value="none"' + (sourceOptions.sort === 'none' ? ' selected' : '') + '>Original order</option><option value="reverse-original"' + (sourceOptions.sort === 'reverse-original' ? ' selected' : '') + '>Reverse original order</option><option value="value-ascending"' + (sourceOptions.sort === 'value-ascending' ? ' selected' : '') + '>Increasing values</option><option value="value-descending"' + (sourceOptions.sort === 'value-descending' ? ' selected' : '') + '>Decreasing values</option></select></div>' +
|
||||
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_limit_' + region.id + '">Maximum items</label><input id="region_chart_source_limit_' + region.id + '" name="region_chart_source_limit_' + region.id + '" class="form-control" type="number" min="0" max="500" value="' + escapeHtml(sourceOptions.limit || 0) + '" placeholder="All" /></div>' +
|
||||
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_missing_' + region.id + '">Missing values</label><select id="region_chart_source_missing_' + region.id + '" name="region_chart_source_missing_' + region.id + '" class="form-select"><option value="gap"' + (sourceOptions.missing === 'gap' ? ' selected' : '') + '>Leave gaps</option><option value="skip"' + (sourceOptions.missing === 'skip' ? ' selected' : '') + '>Skip row</option><option value="zero"' + (sourceOptions.missing === 'zero' ? ' selected' : '') + '>Use zero</option></select></div>' +
|
||||
'</div></div></div></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent, sources) {
|
||||
var current = getCurrentConfig(region, existingContent || {});
|
||||
function value(name, fallback) {
|
||||
var input = card && card.querySelector ? card.querySelector('[name="' + name + '_' + region.id + '"]') : null;
|
||||
return input ? input.value : fallback;
|
||||
}
|
||||
function checked(name, fallback) {
|
||||
var input = card && card.querySelector ? card.querySelector('[name="' + name + '_' + region.id + '"]') : null;
|
||||
return input ? input.checked : fallback;
|
||||
}
|
||||
return {
|
||||
type: 'chart',
|
||||
chart_type: value('region_chart_type', current.type),
|
||||
source_kind: value('region_chart_source_kind', current.source_kind),
|
||||
source_id: value('region_chart_source_id', current.source_id),
|
||||
items_path: value('region_chart_items_path', current.items_path),
|
||||
label_path: value('region_chart_label_path', current.label_path),
|
||||
value_path: value('region_chart_value_path', current.value_path),
|
||||
dataset_label: value('region_chart_dataset_label', current.dataset_label),
|
||||
color: value('region_chart_color', current.color),
|
||||
datasets: parseRows(value('region_chart_datasets', current.datasets_json)),
|
||||
manual_rows: parseRows(value('region_chart_manual_rows', current.manual_rows_json)),
|
||||
chart_options: {
|
||||
legend: value('region_chart_legend', current.chart_options.legend),
|
||||
begin_at_zero: checked('region_chart_begin_at_zero', current.chart_options.begin_at_zero),
|
||||
y_axis_min: value('region_chart_y_axis_min', current.chart_options.y_axis_min),
|
||||
y_axis_max: value('region_chart_y_axis_max', current.chart_options.y_axis_max),
|
||||
line_points: value('region_chart_line_points', current.chart_options.line_points),
|
||||
line_fill: checked('region_chart_line_fill', current.chart_options.line_fill),
|
||||
bar_orientation: value('region_chart_bar_orientation', current.chart_options.bar_orientation),
|
||||
value_format: value('region_chart_value_format', current.chart_options.value_format),
|
||||
decimal_places: value('region_chart_decimal_places', current.chart_options.decimal_places),
|
||||
text_size: value('region_chart_text_size', current.chart_options.text_size),
|
||||
grid_color: value('region_chart_grid_color', current.chart_options.grid_color),
|
||||
text_color: value('region_chart_text_color', current.chart_options.text_color)
|
||||
},
|
||||
source_options: {
|
||||
sort: value('region_chart_source_sort', current.source_options.sort),
|
||||
limit: value('region_chart_source_limit', current.source_options.limit),
|
||||
reverse: false,
|
||||
missing: value('region_chart_source_missing', current.source_options.missing)
|
||||
},
|
||||
sources: Array.isArray(sources) ? sources : []
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('chart', {
|
||||
label: 'Chart',
|
||||
getDefaultRegionSize: function () { return { width: 480, height: 280 }; },
|
||||
getCurrentConfig: getCurrentConfig,
|
||||
renderPreview: renderPreview,
|
||||
initPreview: initPreview,
|
||||
bindEditorCard: bindEditorCard,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -42,14 +42,6 @@ function getColumnsPerRow() {
|
||||
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
|
||||
}
|
||||
|
||||
function showUploadMessage(message) {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, 'warning');
|
||||
return;
|
||||
}
|
||||
window.alert(message);
|
||||
}
|
||||
|
||||
function showModal(target) {
|
||||
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||
window.pulseModal.show(target);
|
||||
@@ -229,12 +221,12 @@ function setUploadFile(file, shouldCrop) {
|
||||
const matchesType = allowedTypes.length === 0 || allowedTypes.indexOf(fileType) !== -1;
|
||||
const maxBytes = fileType.indexOf('video/') === 0 ? Number(uploadInput.dataset.videoMaxBytes) : Number(uploadInput.dataset.imageMaxBytes);
|
||||
if (!matchesType) {
|
||||
showUploadMessage('This media type is disabled in Media Uploads settings.');
|
||||
window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.');
|
||||
uploadInput.value = '';
|
||||
return;
|
||||
}
|
||||
if (maxBytes && file.size > maxBytes) {
|
||||
showUploadMessage('This file is larger than the configured upload limit.');
|
||||
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
|
||||
uploadInput.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +302,7 @@ if (cropApply) {
|
||||
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
|
||||
cropper.getCroppedCanvas().toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
showUploadMessage('Unable to crop this image.');
|
||||
window.PulseUpload.showMessage('Unable to crop this image.');
|
||||
return;
|
||||
}
|
||||
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
|
||||
@@ -372,7 +364,7 @@ if (uploadForm) {
|
||||
uploadForm.addEventListener('submit', function (event) {
|
||||
if (uploadInput && !uploadInput.files.length) {
|
||||
event.preventDefault();
|
||||
showUploadMessage('Choose an image or video to upload.');
|
||||
window.PulseUpload.showMessage('Choose an image or video to upload.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -476,7 +468,7 @@ async function refreshGallery() {
|
||||
visibleRowLimit = 4;
|
||||
updateSearchResults();
|
||||
} catch (error) {
|
||||
showUploadMessage(error.message || 'Unable to refresh media.');
|
||||
window.PulseUpload.showMessage(error.message || 'Unable to refresh media.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +491,7 @@ if (gallery) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (column.dataset.mediaUsed === 'true') {
|
||||
showUploadMessage('This media is in use and cannot be selected.');
|
||||
window.PulseUpload.showMessage('This media is in use and cannot be selected.');
|
||||
return;
|
||||
}
|
||||
const id = String(column.dataset.mediaId || '');
|
||||
@@ -525,7 +517,7 @@ if (loadMoreButton) {
|
||||
visibleRowLimit += 4;
|
||||
updateSearchResults();
|
||||
} catch (error) {
|
||||
showUploadMessage(error.message || 'Unable to load more media.');
|
||||
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
|
||||
} finally {
|
||||
loadMoreButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
(function (window) {
|
||||
var uploadUtils = window.PulseUpload || {};
|
||||
|
||||
uploadUtils.formatUploadLimit = function (bytes) {
|
||||
var megabytes = Number(bytes || 0) / 1024 / 1024;
|
||||
if (megabytes >= 1024 && megabytes % 1024 === 0) {
|
||||
return (megabytes / 1024) + ' GB';
|
||||
}
|
||||
return (Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
|
||||
};
|
||||
|
||||
uploadUtils.showMessage = function (message) {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, 'warning');
|
||||
return;
|
||||
}
|
||||
window.alert(message);
|
||||
};
|
||||
|
||||
window.PulseUpload = uploadUtils;
|
||||
})(window);
|
||||
@@ -615,7 +615,7 @@ export function createSlideFormEditorController(options) {
|
||||
promotion: false,
|
||||
relative_urls: false,
|
||||
remove_script_host: false,
|
||||
convert_urls: true,
|
||||
convert_urls: false,
|
||||
paste_data_images: false,
|
||||
paste_block_drop: true,
|
||||
resize: false,
|
||||
|
||||
@@ -539,7 +539,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
if (module && typeof module.buildEditorCardContext === 'function') {
|
||||
return module.buildEditorCardContext({
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' || region.region_type === 'chart' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
@@ -548,7 +548,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||
uploadMimeTypes: uploadMimeTypes,
|
||||
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : getCurrentRssConfig(region),
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : region.region_type === 'chart' && module && typeof module.getCurrentConfig === 'function' ? module.getCurrentConfig(region, existingContent) : getCurrentRssConfig(region),
|
||||
style: getCurrentTextStyle(region),
|
||||
fontSize: getCurrentTextStyle(region).font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -584,7 +584,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
|
||||
return {
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' ? currentContent : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' || region.region_type === 'chart' ? currentContent : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
@@ -593,7 +593,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||
uploadMimeTypes: uploadMimeTypes,
|
||||
disableAudio: currentContent.disable_audio,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : rssConfig,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : region.region_type === 'chart' ? ((getRegionTypeModule('chart') || {}).getCurrentConfig ? getRegionTypeModule('chart').getCurrentConfig(region, existingContent) : {}) : rssConfig,
|
||||
style: textStyle,
|
||||
fontSize: textStyle.font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -636,11 +636,15 @@ export function createSlideFormRegionHelpers(options) {
|
||||
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
|
||||
var module = null;
|
||||
|
||||
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
if (!card || !card.querySelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
module = getRegionTypeModule('qr-code');
|
||||
} else if (card.querySelector('select[name^="region_chart_source_kind_"]')) {
|
||||
module = getRegionTypeModule('chart');
|
||||
}
|
||||
|
||||
if (module && typeof module.bindEditorCard === 'function') {
|
||||
module.bindEditorCard({
|
||||
|
||||
@@ -6,14 +6,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
(function () {
|
||||
var DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
function formatUploadLimitLabel(bytes) {
|
||||
var megabytes = Number(bytes || 0) / 1024 / 1024;
|
||||
if (megabytes >= 1024 && megabytes % 1024 === 0) {
|
||||
return String(megabytes / 1024) + ' GB';
|
||||
}
|
||||
return (Number.isInteger(megabytes) ? String(megabytes) : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
|
||||
}
|
||||
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
@@ -48,11 +40,11 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||
var uploadLimits = slideEditorData.uploadLimits || {};
|
||||
var uploadMaxBytes = Number(uploadLimits.imageMaxBytes || 100 * 1024 * 1024);
|
||||
var uploadMaxLabel = formatUploadLimitLabel(uploadMaxBytes);
|
||||
var uploadMaxLabel = window.PulseUpload.formatUploadLimit(uploadMaxBytes);
|
||||
var uploadVideoMaxBytes = Number(uploadLimits.videoMaxBytes || 1024 * 1024 * 1024);
|
||||
var uploadVideoMaxLabel = formatUploadLimitLabel(uploadVideoMaxBytes);
|
||||
var uploadVideoMaxLabel = window.PulseUpload.formatUploadLimit(uploadVideoMaxBytes);
|
||||
var wysiwygImageUploadMaxBytes = Number(uploadLimits.wysiwygImageMaxBytes || 2 * 1024 * 1024);
|
||||
var wysiwygImageUploadLimitLabel = formatUploadLimitLabel(wysiwygImageUploadMaxBytes);
|
||||
var wysiwygImageUploadLimitLabel = window.PulseUpload.formatUploadLimit(wysiwygImageUploadMaxBytes);
|
||||
var uploadMimeTypes = Array.isArray(uploadLimits.allowedMimeTypes) ? uploadLimits.allowedMimeTypes : [];
|
||||
var videoDurationCache = Object.create(null);
|
||||
var previewRenderFrame = 0;
|
||||
@@ -690,6 +682,13 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
|
||||
}).join('');
|
||||
|
||||
regions.forEach(function (region) {
|
||||
var typeModule = getRegionTypeModule(region.region_type);
|
||||
if (typeModule && typeof typeModule.initPreview === 'function') {
|
||||
typeModule.initPreview(slidePreviewOverlay);
|
||||
}
|
||||
});
|
||||
|
||||
startPreviewVideoPlayback(slidePreviewOverlay);
|
||||
|
||||
syncPopupPreview();
|
||||
|
||||
@@ -1778,6 +1778,9 @@
|
||||
}
|
||||
if (backgroundInput) {
|
||||
backgroundInput.addEventListener('change', function () {
|
||||
if (backgroundInput.dataset.uploadUrl) {
|
||||
return;
|
||||
}
|
||||
var file = backgroundInput.files && backgroundInput.files[0];
|
||||
if (file) {
|
||||
updateBackgroundPreview(file);
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*!
|
||||
* Chart.js v4.5.1
|
||||
* https://www.chartjs.org
|
||||
* (c) 2025 Chart.js Contributors
|
||||
* Released under the MIT License
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
|
||||
/*!
|
||||
* @kurkle/color v0.3.2
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||
//# sourceMappingURL=chart.umd.js.map
|
||||
@@ -1,6 +1,6 @@
|
||||
// Account password page renderer and return URL normalization.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Admin client command routes for connected screens.
|
||||
// Client routing should rely on the shared player registration helpers instead of recreating
|
||||
// URL and heartbeat checks in each route, which makes the screen-target selection consistent.
|
||||
|
||||
const { createPageAuthToken } = require('#src/request-auth');
|
||||
const { normalizeBaseUrl } = require('#src/data/player-registry');
|
||||
|
||||
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -31,17 +34,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
function normalizeExplicitPlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
return normalizeBaseUrl(value);
|
||||
}
|
||||
|
||||
function normalizeConnectionBaseUrl(connection) {
|
||||
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async function fetchPlayerRegistrations() {
|
||||
if (typeof common.fetchPlayerRegistrations !== 'function') {
|
||||
return [];
|
||||
@@ -87,6 +86,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a screen's live connections back to the player's internal base URL when the public URL
|
||||
// is known to the registry. This lets the admin UI target the correct local endpoint without
|
||||
// depending on whichever public URL happened to be reported by the connection itself.
|
||||
async function resolvePlayerBaseUrlsForConnections(connections) {
|
||||
const connectionList = Array.isArray(connections) ? connections : [];
|
||||
const seen = new Set();
|
||||
@@ -119,6 +121,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
|
||||
}
|
||||
|
||||
// Admin commands may target either a direct player connection or a bridged device. Keep the fan-out
|
||||
// logic explicit so a screen can be controlled through the correct transport without mixing both
|
||||
// path choices into the command payload itself.
|
||||
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
|
||||
const connectionList = Array.isArray(connections) ? connections : [];
|
||||
const targetedConnections = connectionId
|
||||
@@ -186,15 +191,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
|
||||
@@ -7,7 +7,7 @@ const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { fetchMediaAssets, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
|
||||
const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
|
||||
|
||||
module.exports = function registerContentRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -126,6 +126,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function requireSlideUploadPermission(req, res, next) {
|
||||
@@ -147,6 +148,25 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
function requireTemplateUploadPermission(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['templates.create', 'templates.update'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
}
|
||||
|
||||
function getUploadedFileMediaType(file, uploadContext) {
|
||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||
@@ -233,6 +253,23 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function processImmediateMediaUpload(req, res, uploadContext) {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||
}
|
||||
|
||||
await validateUploadedFiles([req.file], uploadContext);
|
||||
await createImageVariants(req.file);
|
||||
await registerMediaAssets(pool, [req.file], function (file) {
|
||||
return '/media/uploads/' + file.filename;
|
||||
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
|
||||
res.json({
|
||||
path: '/media/uploads/' + req.file.filename,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname || ''
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTemplateSlideCount(templateId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM c_slides WHERE template_id = ?', [templateId]);
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
@@ -268,7 +305,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
|
||||
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('#src/web/routes/signage/templates/duplicate');
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
@@ -291,6 +328,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const canvasSizes = await common.fetchCanvasSizesData(pool);
|
||||
const settings = await fetchAppSettings(pool);
|
||||
let duplicateName = buildDuplicateTemplateName(template.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
|
||||
@@ -298,10 +336,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
|
||||
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
});
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
@@ -445,22 +484,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||
}
|
||||
|
||||
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
|
||||
|
||||
await validateUploadedFiles([req.file], uploadContext);
|
||||
await registerMediaAssets(pool, [req.file], function (file) {
|
||||
return '/media/uploads/' + file.filename;
|
||||
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
|
||||
|
||||
res.json({
|
||||
path: '/media/uploads/' + req.file.filename,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname || ''
|
||||
});
|
||||
await processImmediateMediaUpload(req, res, uploadContext);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -485,9 +510,39 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/templates/uploads', requireTemplateUploadPermission, upload.single('file'), async function (req, res, next) {
|
||||
try {
|
||||
await processImmediateMediaUpload(req, res, 'template-background');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/templates/uploads/cleanup', requireTemplateUploadPermission, async function (req, res, next) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: req.body && req.body.uploadPaths
|
||||
? [req.body.uploadPaths]
|
||||
: [];
|
||||
|
||||
if (!uploadPaths.length) {
|
||||
return res.status(400).json({ error: 'No upload paths were provided.' });
|
||||
}
|
||||
|
||||
await removePendingMediaAssets(pool, deps.uploadDir, uploadPaths);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
const settings = await validateUploadedFiles(req.files || []);
|
||||
for (const file of req.files || []) {
|
||||
await createImageVariants(file);
|
||||
}
|
||||
await registerMediaAssets(pool, req.files, function (file) {
|
||||
return '/media/uploads/' + file.filename;
|
||||
}, getUploadedFileMediaType, getAuditUserId(req));
|
||||
@@ -523,7 +578,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
const settings = await validateUploadedFiles(req.files || []);
|
||||
for (const file of req.files || []) {
|
||||
await createImageVariants(file);
|
||||
}
|
||||
await registerMediaAssets(pool, req.files, function (file) {
|
||||
return '/media/uploads/' + file.filename;
|
||||
}, getUploadedFileMediaType, getAuditUserId(req));
|
||||
@@ -632,7 +690,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
|
||||
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
|
||||
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -663,6 +722,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:create:' + result.insertId,
|
||||
pool: pool,
|
||||
@@ -691,7 +751,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
|
||||
const mediaAssets = await fetchMediaAssets(pool);
|
||||
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -745,6 +806,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:update:' + template.id,
|
||||
pool: pool,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Admin manage routes for screens and commands.
|
||||
// Screen command dispatch should not duplicate the global player-registration rules; this route
|
||||
// intentionally relies on the shared registry helpers for consistent URL and freshness checks.
|
||||
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { normalizeBaseUrl } = require('#src/data/player-registry');
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -31,7 +34,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
}
|
||||
|
||||
function normalizeExplicitPlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
return normalizeBaseUrl(value);
|
||||
}
|
||||
|
||||
async function fetchPlayerRegistrations() {
|
||||
@@ -47,14 +50,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
async function fetchLiveConnectionsForScreen(slug) {
|
||||
if (typeof getScreenConnections !== 'function') {
|
||||
return [];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Admin page route composition for the signage sections.
|
||||
|
||||
module.exports = function registerPagesRoutes(app, deps) {
|
||||
require('../signage/dashboard/routes')(app, deps);
|
||||
require('../signage/clients/routes')(app, deps);
|
||||
require('../signage/screens/routes')(app, deps);
|
||||
require('../signage/playlists/routes')(app, deps);
|
||||
require('../signage/slides/routes')(app, deps);
|
||||
require('../signage/canvas-sizes/routes')(app, deps);
|
||||
require('../signage/templates/routes')(app, deps);
|
||||
require('#src/web/routes/signage/dashboard/routes')(app, deps);
|
||||
require('#src/web/routes/signage/clients/routes')(app, deps);
|
||||
require('#src/web/routes/signage/screens/routes')(app, deps);
|
||||
require('#src/web/routes/signage/playlists/routes')(app, deps);
|
||||
require('#src/web/routes/signage/slides/routes')(app, deps);
|
||||
require('#src/web/routes/signage/canvas-sizes/routes')(app, deps);
|
||||
require('#src/web/routes/signage/templates/routes')(app, deps);
|
||||
};
|
||||
@@ -12,9 +12,9 @@
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateRoleName, buildDuplicateRole } = require('../settings/rbac/duplicate');
|
||||
const { buildDuplicateRoleName, buildDuplicateRole } = require('#src/web/routes/settings/rbac/duplicate');
|
||||
|
||||
const ROLE_NAME_MAX_LENGTH = 255;
|
||||
const ROLE_DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateUserName, buildDuplicateUser } = require('../settings/users/duplicate');
|
||||
const { buildDuplicateUserName, buildDuplicateUser } = require('#src/web/routes/settings/users/duplicate');
|
||||
|
||||
const USER_NAME_MAX_LENGTH = 255;
|
||||
const USER_USERNAME_MAX_LENGTH = 255;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderAcceptInvitePage(message, token, email, name, passwordRequirements, isValidInvitation) {
|
||||
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderEmailVerificationErrorPage(message) {
|
||||
return renderView('auth/email-verification-error', { title: 'Verification link unavailable', authShell: true, bodyClass: 'login-page-body', message: message || 'This email verification link is invalid or has expired.' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderEmailVerifiedPage() {
|
||||
return renderView('auth/email-verified', { title: 'Email verified', authShell: true, bodyClass: 'login-page-body' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderForgotPasswordPage(message) {
|
||||
return renderView('auth/forgot-password', { title: 'Reset password', authShell: true, bodyClass: 'login-page-body', message: message || '' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Sign-in page renderer for the authentication flow.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
|
||||
return renderView('auth/login', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderResetPasswordPage(message, token, passwordRequirements) {
|
||||
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// API source add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildApiSourceFormViewModel } = require('#src/web/routes/data-sources/api-sources/form-view-model');
|
||||
|
||||
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// API source edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildApiSourceFormViewModel } = require('#src/web/routes/data-sources/api-sources/form-view-model');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// API source list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// API source route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const renderApiSourcesPage = require('#src/web/routes/data-sources/api-sources/list');
|
||||
const renderApiSourceAddPage = require('#src/web/routes/data-sources/api-sources/add');
|
||||
const renderApiSourceEditPage = require('#src/web/routes/data-sources/api-sources/edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('#src/web/routes/data-sources/api-sources/duplicate');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// RSS feed add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildRssFeedFormViewModel } = require('#src/web/routes/data-sources/rss-feeds/form-view-model');
|
||||
|
||||
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
|
||||
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// RSS feed edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildRssFeedFormViewModel } = require('#src/web/routes/data-sources/rss-feeds/form-view-model');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// RSS feed list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// RSS feed route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const renderRssFeedsPage = require('#src/web/routes/data-sources/rss-feeds/list');
|
||||
const renderRssFeedAddPage = require('#src/web/routes/data-sources/rss-feeds/add');
|
||||
const renderRssFeedEditPage = require('#src/web/routes/data-sources/rss-feeds/edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Timetable group add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildTimetableGroupFormViewModel } = require('#src/web/routes/data-sources/timetables/form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser, options) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false, options));
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user