Release v2.5.9
This commit is contained in:
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.5.9 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Screen playlist refreshes now wait until the next slide transition before applying a pending update, so one-slide playlists do not swap immediately during a refresh.
|
||||||
|
- Playlist rows now show the mute control for any playable media region, including RTMP slides, instead of only video regions.
|
||||||
|
|
||||||
|
## 2.5.8 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- MariaDB migrations now retry alternate foreign-key constraint names when screen-to-player constraint creation reports a duplicate-key error.
|
||||||
|
|
||||||
## 2.5.7 - 2026-08-05
|
## 2.5.7 - 2026-08-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.6",
|
"version": "2.5.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.6",
|
"version": "2.5.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.7",
|
"version": "2.5.9",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
|
|||||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
|
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826 && error.errno !== 121)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
slideExpiresAt = null;
|
slideExpiresAt = null;
|
||||||
pausedRemainingMs = null;
|
pausedRemainingMs = null;
|
||||||
clearSlideOutroTimer();
|
clearSlideOutroTimer();
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
if (activeSlides.length < 2) {
|
if (activeSlides.length < 2) {
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ function applyPendingPlaylistUpdate() {
|
|||||||
// Render the current active slide or the empty state.
|
// Render the current active slide or the empty state.
|
||||||
function showCurrent() {
|
function showCurrent() {
|
||||||
clearSlideTimer();
|
clearSlideTimer();
|
||||||
applyPendingPlaylistUpdate();
|
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
syncWebpagePreloads(activeSlides, index);
|
syncWebpagePreloads(activeSlides, index);
|
||||||
if (typeof syncRtmpWarmups === 'function') {
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
@@ -202,14 +201,13 @@ function refresh() {
|
|||||||
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
||||||
}
|
}
|
||||||
if (currentActiveSlides.length < 2) {
|
if (currentActiveSlides.length < 2) {
|
||||||
slides = nextSlides;
|
pendingPlaylistUpdate = {
|
||||||
currentPlaylistSignature = nextSignature;
|
slides: nextSlides,
|
||||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
signature: nextSignature,
|
||||||
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
pendingPlaylistUpdate = null;
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
index = 0;
|
};
|
||||||
showCurrent();
|
logDebug('Playlist update detected; applying on next slide transition.');
|
||||||
sendCommandState(lastRenderedSlide);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingPlaylistUpdate = {
|
pendingPlaylistUpdate = {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
|||||||
isLast: index === items.length - 1,
|
isLast: index === items.length - 1,
|
||||||
canvasSizeId: Number(item.canvas_size_id) || null,
|
canvasSizeId: Number(item.canvas_size_id) || null,
|
||||||
showVideoDurationButton: hasVideoRegion(item.content_json),
|
showVideoDurationButton: hasVideoRegion(item.content_json),
|
||||||
showMuteButton: hasVideoRegion(item.content_json),
|
showMuteButton: hasPlayableMediaRegion(item.content_json),
|
||||||
videoSourcePath: getVideoSourcePath(item),
|
videoSourcePath: getVideoSourcePath(item),
|
||||||
videoDurationSeconds: getVideoDurationSeconds(item),
|
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||||
useVideoDuration: Boolean(item.use_video_duration),
|
useVideoDuration: Boolean(item.use_video_duration),
|
||||||
@@ -221,7 +221,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
|||||||
canvasSizeId: Number(slide.canvas_size_id) || null,
|
canvasSizeId: Number(slide.canvas_size_id) || null,
|
||||||
isAssigned: assignedSlideIds.has(slide.id),
|
isAssigned: assignedSlideIds.has(slide.id),
|
||||||
showVideoDurationButton: hasVideoRegion(slide.content_json),
|
showVideoDurationButton: hasVideoRegion(slide.content_json),
|
||||||
showMuteButton: hasVideoRegion(slide.content_json),
|
showMuteButton: hasPlayableMediaRegion(slide.content_json),
|
||||||
videoSourcePath: getVideoSourcePath(slide),
|
videoSourcePath: getVideoSourcePath(slide),
|
||||||
videoDurationSeconds: getVideoDurationSeconds(slide),
|
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||||
useVideoDuration: Boolean(slide.use_video_duration),
|
useVideoDuration: Boolean(slide.use_video_duration),
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
test('playlist refresh queues updates until the next slide transition', async () => {
|
||||||
|
const calls = {
|
||||||
|
showCurrent: 0,
|
||||||
|
logDebug: [],
|
||||||
|
scheduleSlideAdvance: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
window: null,
|
||||||
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
||||||
|
Date,
|
||||||
|
JSON,
|
||||||
|
Math,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
Promise,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
console,
|
||||||
|
currentPlaylistSignature: 'old-signature',
|
||||||
|
currentPlaylistFadeBetweenSlides: false,
|
||||||
|
currentPlaylistSkipUnavailableRtmp: false,
|
||||||
|
slug: 'test2',
|
||||||
|
pendingPlaylistUpdate: null,
|
||||||
|
slides: [{ id: 1, duration_seconds: 12 }],
|
||||||
|
lastRenderedSlide: { id: 1, duration_seconds: 12 },
|
||||||
|
index: 0,
|
||||||
|
timer: null,
|
||||||
|
slideExpiresAt: null,
|
||||||
|
pausedRemainingMs: null,
|
||||||
|
currentPlaylistEtag: '',
|
||||||
|
activeSlidesCacheKey: '',
|
||||||
|
activeSlidesCacheValue: [],
|
||||||
|
renderCacheViewportKey: '',
|
||||||
|
slideMarkupCache: Object.create(null),
|
||||||
|
templateLayoutCache: Object.create(null),
|
||||||
|
templateRenderPlanCache: Object.create(null),
|
||||||
|
getCurrentActiveSlides() {
|
||||||
|
return sandbox.slides;
|
||||||
|
},
|
||||||
|
getPlaylistRevision(data) {
|
||||||
|
return data.signature;
|
||||||
|
},
|
||||||
|
normalizeSlide(slide) {
|
||||||
|
return slide;
|
||||||
|
},
|
||||||
|
getActiveSlidesFrom(slideList) {
|
||||||
|
return slideList;
|
||||||
|
},
|
||||||
|
savePlaylistSnapshot() {},
|
||||||
|
markRefreshHealthy() {},
|
||||||
|
setOfflineBannerVisible() {},
|
||||||
|
scheduleRefreshRetry() {},
|
||||||
|
syncWebpagePreloads() {},
|
||||||
|
syncRtmpWarmups() {},
|
||||||
|
showCurrent() {
|
||||||
|
calls.showCurrent += 1;
|
||||||
|
},
|
||||||
|
sendCommandState() {},
|
||||||
|
scheduleSlideAdvance(delayMs) {
|
||||||
|
calls.scheduleSlideAdvance.push(delayMs);
|
||||||
|
},
|
||||||
|
logDebug(...args) {
|
||||||
|
calls.logDebug.push(args.join(' '));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
class XhrStub {
|
||||||
|
open(method, url) {
|
||||||
|
this.method = method;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestHeader() {}
|
||||||
|
|
||||||
|
getResponseHeader(name) {
|
||||||
|
return name === 'ETag' ? '"next-etag"' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
send() {
|
||||||
|
this.readyState = 4;
|
||||||
|
this.status = 200;
|
||||||
|
this.responseText = JSON.stringify({
|
||||||
|
signature: 'next-signature',
|
||||||
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
||||||
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
||||||
|
});
|
||||||
|
if (typeof this.onreadystatechange === 'function') {
|
||||||
|
this.onreadystatechange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sandbox.XMLHttpRequest = XhrStub;
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
||||||
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||||
|
const prelude = `
|
||||||
|
var pendingPlaylistUpdate = null;
|
||||||
|
var slides = [{ id: 1, duration_seconds: 12 }];
|
||||||
|
var currentPlaylistSignature = 'old-signature';
|
||||||
|
var currentPlaylistFadeBetweenSlides = false;
|
||||||
|
var currentPlaylistSkipUnavailableRtmp = false;
|
||||||
|
var currentPlaylistEtag = '';
|
||||||
|
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
|
||||||
|
var index = 0;
|
||||||
|
var timer = null;
|
||||||
|
var slideExpiresAt = null;
|
||||||
|
var pausedRemainingMs = null;
|
||||||
|
var activeSlidesCacheKey = '';
|
||||||
|
var activeSlidesCacheValue = [];
|
||||||
|
var renderCacheViewportKey = '';
|
||||||
|
var slideMarkupCache = Object.create(null);
|
||||||
|
var templateLayoutCache = Object.create(null);
|
||||||
|
var templateRenderPlanCache = Object.create(null);
|
||||||
|
`;
|
||||||
|
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
||||||
|
|
||||||
|
await sandbox.refresh();
|
||||||
|
|
||||||
|
assert.equal(calls.showCurrent, 0);
|
||||||
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
||||||
|
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
||||||
|
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
||||||
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
||||||
|
});
|
||||||
@@ -68,6 +68,22 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
canvas_size_height: 1080,
|
canvas_size_height: 1080,
|
||||||
canvas_width: 1920,
|
canvas_width: 1920,
|
||||||
canvas_height: 1080
|
canvas_height: 1080
|
||||||
|
}, {
|
||||||
|
id: 102,
|
||||||
|
title: 'Live Feed',
|
||||||
|
template_id: 33,
|
||||||
|
content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}',
|
||||||
|
modified_at: '2026-08-03T00:00:01.000Z',
|
||||||
|
position: 2,
|
||||||
|
duration_seconds: 15,
|
||||||
|
use_video_duration: 0,
|
||||||
|
disable_audio: 1,
|
||||||
|
template_name: 'Template 33',
|
||||||
|
canvas_size_name: 'HD',
|
||||||
|
canvas_size_width: 1920,
|
||||||
|
canvas_size_height: 1080,
|
||||||
|
canvas_width: 1920,
|
||||||
|
canvas_height: 1080
|
||||||
}]];
|
}]];
|
||||||
}
|
}
|
||||||
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
|
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
|
||||||
@@ -77,11 +93,11 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
if (sql.includes('FROM c_templates st')) {
|
if (sql.includes('FROM c_templates st')) {
|
||||||
assert.deepEqual(params, [[33]]);
|
assert.deepEqual(params, [[33, 33]]);
|
||||||
return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
|
return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
|
||||||
}
|
}
|
||||||
if (sql.includes('FROM c_template_regions')) {
|
if (sql.includes('FROM c_template_regions')) {
|
||||||
assert.deepEqual(params, [[33]]);
|
assert.deepEqual(params, [[33, 33]]);
|
||||||
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
|
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
|
||||||
}
|
}
|
||||||
throw new Error(`unexpected query: ${sql}`);
|
throw new Error(`unexpected query: ${sql}`);
|
||||||
@@ -111,6 +127,8 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
assert.equal(payload.slides[0].disable_audio, true);
|
assert.equal(payload.slides[0].disable_audio, true);
|
||||||
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
||||||
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
||||||
|
assert.equal(payload.slides[1].disable_audio, true);
|
||||||
|
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
|
||||||
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
|
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
|
||||||
assert.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
|
assert.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
|
||||||
assert.equal(payload.slides[0].scheduleRules.length, 2);
|
assert.equal(payload.slides[0].scheduleRules.length, 2);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model');
|
||||||
|
|
||||||
|
test('playlist rows show mute controls for RTMP slides', () => {
|
||||||
|
const model = buildPlaylistFormViewModel(
|
||||||
|
{ id: 22, name: 'Playlist 22' },
|
||||||
|
{
|
||||||
|
playlistSlides: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
playlist_id: 22,
|
||||||
|
slide_id: 101,
|
||||||
|
position: 1,
|
||||||
|
title: 'Video slide',
|
||||||
|
content_json: '{"videoRegion":{"type":"video","value":"/media/video.mp4"}}',
|
||||||
|
duration_seconds: 10,
|
||||||
|
use_video_duration: 0,
|
||||||
|
disable_audio: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
playlist_id: 22,
|
||||||
|
slide_id: 102,
|
||||||
|
position: 2,
|
||||||
|
title: 'RTMP slide',
|
||||||
|
content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}',
|
||||||
|
duration_seconds: 10,
|
||||||
|
use_video_duration: 0,
|
||||||
|
disable_audio: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
playlist_id: 22,
|
||||||
|
slide_id: 103,
|
||||||
|
position: 3,
|
||||||
|
title: 'Image slide',
|
||||||
|
content_json: '{"imageRegion":{"type":"image","value":"/media/image.png"}}',
|
||||||
|
duration_seconds: 10,
|
||||||
|
use_video_duration: 0,
|
||||||
|
disable_audio: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
slides: []
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
{ isEdit: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(model.playlistSlides[0].showMuteButton, true);
|
||||||
|
assert.equal(model.playlistSlides[1].showMuteButton, true);
|
||||||
|
assert.equal(model.playlistSlides[2].showMuteButton, false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user