Compare commits

...
4 Commits
Author SHA1 Message Date
lzstealth b9dd4178c4 Apply remaining changes 2026-08-05 21:57:16 +01:00
lzstealth a6a5d71ef0 Release v2.5.11 2026-08-05 21:56:46 +01:00
lzstealth c55c3d2baf Release v2.5.10 2026-08-05 21:25:02 +01:00
lzstealth d3e059a878 Release v2.5.9 2026-08-05 21:05:43 +01:00
18 changed files with 756 additions and 69 deletions
+27
View File
@@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file.
## 2.5.11 - 2026-08-05
### Fixed
- Single-slide playlists now re-render the active slide immediately when a refresh arrives, instead of waiting for a transition that never happens.
- Playlist create and update flows now redirect to the canonical `/playlists/:id/edit` path after saving.
- Slide preview popups now keep rich-text spacing and line height consistent with the editor preview.
## 2.5.10 - 2026-08-05
### Fixed
- Slide editor rich-text updates now keep the hidden field and preview state in sync after inline formatting actions.
## 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
### Fixed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.5.6",
"version": "2.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.5.6",
"version": "2.5.9",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.5.7",
"version": "2.5.11",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+1 -1
View File
@@ -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);
return;
} 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;
}
}
-12
View File
@@ -395,18 +395,6 @@ body.screen-blackout #app {
white-space: pre-wrap;
}
.template-region.text > *,
.template-region.api > *,
.template-region.rss > * {
margin: 0;
}
.template-region.text > * + *,
.template-region.api > * + *,
.template-region.rss > * + * {
margin-top: 0.5em;
}
.template-region.text ul,
.template-region.text ol,
.template-region.api ul,
+2 -1
View File
@@ -130,9 +130,10 @@ function scheduleSlideAdvance(delayMs) {
slideExpiresAt = null;
pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
refresh();
showCurrent();
return;
}
if (index >= activeSlides.length) {
+26 -12
View File
@@ -85,7 +85,6 @@ function applyPendingPlaylistUpdate() {
// Render the current active slide or the empty state.
function showCurrent() {
clearSlideTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpWarmups === 'function') {
@@ -100,6 +99,16 @@ function showCurrent() {
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
}
function isSlideInList(slide, slideList) {
if (!slide || !Array.isArray(slideList)) {
return false;
}
return slideList.some(function (item) {
return item && Number(item.id) === Number(slide.id);
});
}
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) {
@@ -166,6 +175,7 @@ function refresh() {
const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
savePlaylistSnapshot({
@@ -197,19 +207,23 @@ function refresh() {
}
return;
}
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
syncWebpagePreloads(nextActiveSlides, index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
syncRtmpWarmups(nextActiveSlides, index);
}
if (currentActiveSlides.length < 2) {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
pendingPlaylistUpdate = null;
index = 0;
showCurrent();
sendCommandState(lastRenderedSlide);
if (nextActiveSlides.length < 2) {
pendingPlaylistUpdate = {
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
applyPendingPlaylistUpdate();
showCurrent();
return;
}
logDebug('Playlist update detected; applying on next slide transition.');
return;
}
pendingPlaylistUpdate = {
+1 -1
View File
@@ -61,7 +61,7 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
if (cached) {
return cached;
}
throw _error;
return new Response('', { status: 504, statusText: 'Offline' });
}
}
+25
View File
@@ -1448,6 +1448,31 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
box-sizing: border-box;
}
.slide-preview-text-content {
line-height: 1.5;
}
.slide-preview-text-content > * {
margin: 1em 0;
}
.slide-preview-text-content > *:first-child {
margin-top: 0;
}
.slide-preview-text-content > *:last-child {
margin-bottom: 1em;
}
.slide-preview-text-content ul,
.slide-preview-text-content ol {
padding-left: 1.2em;
}
.slide-preview-text-content code {
white-space: pre-wrap;
}
.slide-preview-image {
width: 100%;
height: 100%;
+1 -1
View File
@@ -38,7 +38,7 @@
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>';
}
+33 -32
View File
@@ -94,6 +94,39 @@ export function createSlideFormEditorController(options) {
};
}
function syncEditorState(regionId, editor, shouldLock, hydrated) {
if (editor && typeof editor.save === 'function') {
editor.save();
}
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
if (hidden) {
hidden.value = content;
}
if (sourceElm) {
sourceElm.value = content;
}
if (typeof editor.nodeChanged === 'function') {
editor.nodeChanged();
}
if (typeof editor.setDirty === 'function') {
editor.setDirty(true);
}
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
templateSelectorLock.arm();
}
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
templateSelectorLock.markEdited();
}
if (hydrated) {
requestPreviewRender();
}
}
function getEditorRegionType(card) {
if (!card) {
return '';
@@ -228,38 +261,6 @@ export function createSlideFormEditorController(options) {
});
}
function syncEditorState(regionId, editor, shouldLock, hydrated) {
if (editor && typeof editor.save === 'function') {
editor.save();
}
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
if (hidden) {
hidden.value = content;
}
if (sourceElm) {
sourceElm.value = content;
}
if (typeof editor.nodeChanged === 'function') {
editor.nodeChanged();
}
if (typeof editor.setDirty === 'function') {
editor.setDirty(true);
}
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
templateSelectorLock.arm();
}
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
templateSelectorLock.markEdited();
}
if (hydrated) {
requestPreviewRender();
}
}
function applyInlineFormat(regionId, editor, formatName) {
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
editor.undoManager.transact(function () {
+2 -2
View File
@@ -295,7 +295,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
};
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
await connection.commit();
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
redirectAfterSave(req, res, '/playlists/' + result.insertId + '/edit', {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
@@ -352,7 +352,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
@@ -193,7 +193,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
isLast: index === items.length - 1,
canvasSizeId: Number(item.canvas_size_id) || null,
showVideoDurationButton: hasVideoRegion(item.content_json),
showMuteButton: hasVideoRegion(item.content_json),
showMuteButton: hasPlayableMediaRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
@@ -221,7 +221,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
canvasSizeId: Number(slide.canvas_size_id) || null,
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
showMuteButton: hasVideoRegion(slide.content_json),
showMuteButton: hasPlayableMediaRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
@@ -27,6 +27,31 @@
box-sizing: border-box;
display: block;
}
.slide-preview-text-content {
line-height: 1.5;
}
.slide-preview-text-content > * {
margin: 1em 0;
}
.slide-preview-text-content > *:first-child {
margin-top: 0;
}
.slide-preview-text-content > *:last-child {
margin-bottom: 1em;
}
.slide-preview-text-content ul,
.slide-preview-text-content ol {
padding-left: 1.2em;
}
.slide-preview-text-content code {
white-space: pre-wrap;
}
</style>
<div class="slide-preview-popup-stage" id="popup-preview-stage">
+407
View File
@@ -0,0 +1,407 @@
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,
app: 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 app = 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);
});
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
const calls = {
showCurrent: 0,
refresh: 0
};
const timers = [];
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout(fn) {
timers.push(fn);
return timers.length;
},
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() {},
clearActiveSlidesCache() {},
showCurrent() {},
sendCommandState() {},
refresh() {
calls.refresh += 1;
},
logDebug() {}
};
sandbox.window = sandbox;
class XhrStub {
open() {}
setRequestHeader() {}
getResponseHeader(name) {
return name === 'ETag' ? '"next-etag"' : '';
}
send() {
this.readyState = 4;
this.status = 200;
this.responseText = JSON.stringify({
signature: 'next-signature',
slides: [{ id: 2, 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 commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, '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 app = 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' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
sandbox.showCurrent = function () {
calls.showCurrent += 1;
};
sandbox.pendingPlaylistUpdate = {
slides: [{ id: 2, duration_seconds: 12 }],
signature: 'next-signature',
fadeBetweenSlides: false,
skipUnavailableRtmp: false
};
sandbox.scheduleSlideAdvance(100);
assert.equal(timers.length, 1);
timers.shift()();
assert.equal(calls.refresh, 0);
assert.equal(calls.showCurrent, 1);
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
});
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
const calls = {
showCurrent: 0,
logDebug: []
};
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 }, { id: 2, duration_seconds: 12 }],
lastRenderedSlide: { id: 2, duration_seconds: 12 },
index: 1,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: 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() {},
clearActiveSlidesCache() {},
showCurrent() {
calls.showCurrent += 1;
},
sendCommandState() {},
refresh() {},
logDebug(...args) {
calls.logDebug.push(args.join(' '));
}
};
sandbox.window = sandbox;
class XhrStub {
open() {}
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 playbackScript = fs.readFileSync(scriptPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
var index = 1;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = 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' + playbackScript, sandbox, { filename: scriptPath });
await sandbox.refresh();
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [{ id: 1, duration_seconds: 12, disable_audio: false }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), false);
});
+20 -2
View File
@@ -68,6 +68,22 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
canvas_size_height: 1080,
canvas_width: 1920,
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')) {
@@ -77,11 +93,11 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
]];
}
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' }]];
}
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' }]];
}
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].content.videoRegion.disable_audio, true);
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.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
assert.equal(payload.slides[0].scheduleRules.length, 2);
+57
View File
@@ -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);
});
+124
View File
@@ -0,0 +1,124 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
function createAppAndHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
return { app, handlers };
}
function createResponse() {
return {
redirectedTo: '',
statusCode: 200,
redirect(url) {
this.redirectedTo = url;
},
status(code) {
this.statusCode = code;
return this;
},
send() {
return this;
}
};
}
test('playlist create and update redirect to the canonical edit path', async () => {
const calls = [];
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql) {
if (sql.startsWith('INSERT INTO c_playlists')) {
return [{ insertId: 4 }];
}
if (sql.startsWith('UPDATE c_playlists SET')) {
calls.push('update-playlist');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
calls.push('delete-slides');
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: {
async getConnection() {
return connection;
}
},
common: {
async fetchDuplicateName() { return null; },
async fetchCanvasSizeById() { return { id: 7 }; },
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
},
pages: {},
fetchOrderedPlaylistSlides: async () => [],
fetchScreensByPlaylistId: async () => [],
fetchPlaylistCanvasId: async () => null,
readArrayField: () => [],
getAuditUserId: () => 12,
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
res.redirect(url);
},
notifyPlayerScreens: async () => {},
broadcastDashboardState: async () => {},
getPlaylistDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const createHandlers = handlers['/playlists'];
const updateHandlers = handlers['/playlists/:id'];
assert.equal(Array.isArray(createHandlers), true);
assert.equal(Array.isArray(updateHandlers), true);
const createRes = createResponse();
await createHandlers[1]({
path: '/playlists',
body: {
name: 'Lunch Loop',
canvas_size_id: '7',
slide_id: []
}
}, createRes, () => {});
const updateRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: {
name: 'Lunch Loop',
slide_id: []
}
}, updateRes, () => {});
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
assert.equal(updateRes.redirectedTo, '/playlists/4/edit');
assert.deepEqual(calls, [
'delete-slides',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
'update-playlist',
'delete-slides',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
]);
});