diff --git a/CHANGELOG.md b/CHANGELOG.md
index 251f3dc..fe99dd7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
+## 2.8.4 - 2026-08-17
+
+### Added
+
+- Added local caching for API and RSS image placeholders under `player-cache/remote-images` for offline player playback.
+- Added reconciliation of cached remote images so files no longer referenced by slides are removed.
+
+### Fixed
+
+- Fixed popup preview authentication for background thumbnail capture.
+- Fixed thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly.
+
## 2.8.3 - 2026-08-17
### Added
diff --git a/build/package.player.json b/build/package.player.json
index e4396e6..b7616d9 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
- "version": "2.8.3",
+ "version": "2.8.4",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
diff --git a/build/package.web.json b/build/package.web.json
index 44113cd..8a1176c 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
- "version": "2.8.3",
+ "version": "2.8.4",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
diff --git a/package-lock.json b/package-lock.json
index 45bcf7c..9dbbb84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
- "version": "2.8.3",
+ "version": "2.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "2.8.3",
+ "version": "2.8.4",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
diff --git a/package.json b/package.json
index 67ee60d..9c620d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.8.3",
+ "version": "2.8.4",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js
index c18c193..825fbe0 100644
--- a/src/player-bridge/index.js
+++ b/src/player-bridge/index.js
@@ -187,6 +187,7 @@ async function start() {
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common,
+ mediaDir: config.mediaDir,
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
});
app.use(express.json());
diff --git a/src/player.js b/src/player.js
index 12f82e8..3611f49 100644
--- a/src/player.js
+++ b/src/player.js
@@ -61,6 +61,7 @@ async function start() {
: createPlayerPlaylistService({
pool: pool,
common: common,
+ mediaDir: MEDIA_DIR,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
});
const rtmpStreamService = createRtmpStreamService({
diff --git a/src/player/playlist.js b/src/player/playlist.js
index a27e123..4c5250c 100644
--- a/src/player/playlist.js
+++ b/src/player/playlist.js
@@ -9,6 +9,8 @@ function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
+ const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null;
+ const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null;
if (!pool) {
throw new Error('pool is required');
@@ -61,6 +63,105 @@ function createPlayerPlaylistService(options) {
return createStyledQrCodeDataUrl(value);
}
+ function getPlaceholderImageExpressions(value, output) {
+ const expressions = output || [];
+ const source = String(value || '');
+ const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi;
+ let match = null;
+ while ((match = pattern.exec(source))) {
+ expressions.push(String(match[1] || '').trim());
+ }
+ return expressions;
+ }
+
+ function resolvePathValue(value, expression) {
+ const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
+ return parsed.split('.').reduce(function (current, segment) {
+ return current === undefined || current === null ? '' : current[segment];
+ }, value);
+ }
+
+ function getApiItems(responseJson, itemsPath) {
+ let current = responseJson;
+ const pathValue = String(itemsPath || '').trim();
+ if (pathValue) {
+ pathValue.split('.').forEach(function (segment) {
+ current = current === undefined || current === null ? '' : current[segment];
+ });
+ return Array.isArray(current) ? current : [];
+ }
+ if (Array.isArray(responseJson)) return responseJson;
+ if (responseJson && Array.isArray(responseJson.items)) return responseJson.items;
+ if (responseJson && Array.isArray(responseJson.results)) return responseJson.results;
+ if (responseJson && Array.isArray(responseJson.data)) return responseJson.data;
+ return responseJson ? [responseJson] : [];
+ }
+
+ async function cacheRemoteImage(url) {
+ const remoteUrl = String(url || '').trim();
+ if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return '';
+ const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex');
+ await fs.promises.mkdir(remoteImageCacheDir, { recursive: true });
+ const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); });
+ if (existing) return '/media/player-cache/remote-images/' + existing;
+ try {
+ const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) });
+ if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return '';
+ const bytes = Buffer.from(await response.arrayBuffer());
+ if (bytes.length > 10 * 1024 * 1024) return '';
+ const contentType = String(response.headers.get('content-type') || '').toLowerCase();
+ const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg';
+ const fileName = hash + extension;
+ const filePath = path.join(remoteImageCacheDir, fileName);
+ await fs.promises.writeFile(filePath, bytes);
+ return '/media/player-cache/remote-images/' + fileName;
+ } catch (_error) {
+ return '';
+ }
+ }
+
+ async function cachePlaceholderImages(slides, rssFeeds, apiSources) {
+ if (!remoteImageCacheDir) return;
+ const usedPaths = new Set();
+ const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; });
+ const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; });
+ const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; }));
+ const cacheSource = async function (source, item, expressions) {
+ if (!source || !item) return;
+ for (const expression of expressions) {
+ const remoteUrl = String(resolvePathValue(item, expression) || '').trim();
+ const localPath = await cacheRemoteImage(remoteUrl);
+ if (localPath) {
+ source.imageCache = source.imageCache || {};
+ source.imageCache[remoteUrl] = localPath;
+ usedPaths.add(localPath);
+ }
+ }
+ };
+ for (const content of sourceContent) {
+ for (const key of Object.keys(content || {})) {
+ const region = content[key];
+ if (!region || typeof region !== 'object') continue;
+ const expressions = getPlaceholderImageExpressions(region.value, []);
+ if (!expressions.length) continue;
+ const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1;
+ if (String(region.type || '').toLowerCase() === 'api') {
+ const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); });
+ const items = source ? getApiItems(source.responseJson, region.items_path) : [];
+ await cacheSource(source, items[itemNumber], expressions);
+ }
+ if (String(region.type || '').toLowerCase() === 'rss') {
+ const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); });
+ await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions);
+ }
+ }
+ }
+ const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; });
+ await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) {
+ return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {});
+ }));
+ }
+
async function buildScreenPlaylist(slug) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
@@ -241,6 +342,8 @@ function createPlayerPlaylistService(options) {
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
+ await cachePlaceholderImages(slides, rssFeeds, apiSources);
+
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
await writeSnapshot(slug, payload);
diff --git a/src/player/regions/api.js b/src/player/regions/api.js
index daa593c..447a7ba 100644
--- a/src/player/regions/api.js
+++ b/src/player/regions/api.js
@@ -56,7 +56,7 @@ function getApiItem(sourceId, itemNumber, itemsPathOverride) {
return items[index] || null;
}
-function substituteApiVariables(html, item) {
+function substituteApiVariables(html, item, sourceId) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
@@ -70,6 +70,8 @@ function substituteApiVariables(html, item) {
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
+ var source = getApiSourceById(sourceId);
+ imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
@@ -137,7 +139,7 @@ function renderApiRegion(region, regionContent) {
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber, itemsPath);
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
- var body = hasSource ? (item ? substituteApiVariables(content, item) : '') : content;
+ var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content;
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
if (!body) {
return '';
diff --git a/src/player/regions/rss.js b/src/player/regions/rss.js
index a36c76f..f17d538 100644
--- a/src/player/regions/rss.js
+++ b/src/player/regions/rss.js
@@ -64,7 +64,7 @@ function resolveRssPath(value, path) {
return current === undefined || current === null ? '' : current;
}
-function substituteRssVariables(html, item) {
+function substituteRssVariables(html, item, feedId) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
@@ -76,6 +76,8 @@ function substituteRssVariables(html, item) {
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
+ var feed = getRssFeedById(feedId);
+ imageSource = feed && feed.imageCache && feed.imageCache[imageSource] ? feed.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
@@ -103,7 +105,7 @@ function renderRssRegion(region, regionContent) {
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getRssFeedItem(feedId, itemNumber);
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
- var body = hasFeed ? (item ? substituteRssVariables(content, item) : '') : content;
+ var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content;
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
if (!body) {
return '';
diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js
index a478e65..c57673f 100644
--- a/src/web/lib/media/slide-thumbnails.js
+++ b/src/web/lib/media/slide-thumbnails.js
@@ -2,6 +2,8 @@
const fs = require('fs');
const path = require('path');
+const crypto = require('crypto');
+const vm = require('vm');
const {
escapeHtml,
mediaKind,
@@ -10,7 +12,13 @@ const {
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
-const { createRequestAuthHeaders } = require('#src/request-auth');
+const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
+
+const placeholderUtils = (() => {
+ const sandbox = { window: {} };
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8'), sandbox);
+ return sandbox.window.placeholderUtils || {};
+})();
const SYSTEM_CHROMIUM_PATHS = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -124,11 +132,51 @@ function hasVisibleContent(html) {
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
}
-function buildTextRegionMarkup(region, regionContent) {
+function resolvePlaceholderPath(value, expression) {
+ const pathValue = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
+ return pathValue.split('.').reduce(function (current, segment) {
+ return current === undefined || current === null ? '' : current[segment];
+ }, value);
+}
+
+function getImagePlaceholderConfig(expression) {
+ const match = String(expression || '').match(/\.image\s*\(\s*([0-9]+)?\s*,?\s*([0-9]+)?\s*\)/i);
+ return {
+ width: match && match[1] ? Number(match[1]) : 0,
+ height: match && match[2] ? Number(match[2]) : 0
+ };
+}
+
+function substitutePlaceholders(html, regionContent, options) {
+ const source = String(html || '');
+ const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
+ const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
+ if (!item) return source;
+ return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
+ const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
+ ? placeholderUtils.resolvePlaceholderExpression(item, expression)
+ : resolvePlaceholderPath(item, expression);
+ const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
+ if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
+ return escapeHtml(value);
+ }
+ const remoteUrl = String(value || '').trim();
+ if (!/^https?:\/\//i.test(remoteUrl)) return '';
+ const imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : getImagePlaceholderConfig(expression);
+ const cacheFile = options && typeof options.getCachedImagePath === 'function' ? options.getCachedImagePath(remoteUrl) : '';
+ const imageUrl = cacheFile || remoteUrl;
+ const style = imageConfig.width && imageConfig.height
+ ? 'display:block;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;object-fit:contain;'
+ : 'display:block;max-width:' + (imageConfig.width || 100) + 'px;max-height:' + (imageConfig.height || 100) + 'px;width:auto;height:auto;';
+ return '';
+ });
+}
+
+function buildTextRegionMarkup(region, regionContent, options) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
- const renderedBody = renderEditorJsContent(regionContent.value || '');
+ const renderedBody = renderEditorJsContent(substitutePlaceholders(regionContent.value || '', regionContent, options));
if (!hasVisibleContent(renderedBody)) {
return '';
}
@@ -136,7 +184,7 @@ function buildTextRegionMarkup(region, regionContent) {
return '