From d3e059a878979734d9b6a393587bef7fa01b0530 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Wed, 5 Aug 2026 21:05:43 +0100 Subject: [PATCH] Release v2.5.9 --- CHANGELOG.md | 13 ++ package-lock.json | 4 +- package.json | 2 +- src/db/migrations.js | 2 +- src/player/public/js/player-page-commands.js | 1 + src/player/public/js/player-page-playback.js | 16 +-- .../signage/playlists/form-view-model.js | 4 +- test/player-page-playback.test.js | 136 ++++++++++++++++++ test/player-playlist.test.js | 22 ++- test/playlist-form-view-model.test.js | 57 ++++++++ 10 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 test/player-page-playback.test.js create mode 100644 test/playlist-form-view-model.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fa2ef..4d31d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ 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 ### Fixed diff --git a/package-lock.json b/package-lock.json index 6772fc8..59e5768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8de5a2e..17bff15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.5.7", + "version": "2.5.9", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/db/migrations.js b/src/db/migrations.js index 0acf094..9a2c262 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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; } } diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index ca6001a..6eae6b1 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -130,6 +130,7 @@ function scheduleSlideAdvance(delayMs) { slideExpiresAt = null; pausedRemainingMs = null; clearSlideOutroTimer(); + applyPendingPlaylistUpdate(); const activeSlides = getCurrentActiveSlides(); if (activeSlides.length < 2) { refresh(); diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index b26036c..cd1753f 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -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') { @@ -202,14 +201,13 @@ function refresh() { syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index); } if (currentActiveSlides.length < 2) { - slides = nextSlides; - currentPlaylistSignature = nextSignature; - currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; - currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp; - pendingPlaylistUpdate = null; - index = 0; - showCurrent(); - sendCommandState(lastRenderedSlide); + pendingPlaylistUpdate = { + slides: nextSlides, + signature: nextSignature, + fadeBetweenSlides: nextFadeBetweenSlides, + skipUnavailableRtmp: nextSkipUnavailableRtmp + }; + logDebug('Playlist update detected; applying on next slide transition.'); return; } pendingPlaylistUpdate = { diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index d28bb5a..bc4236d 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -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), diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js new file mode 100644 index 0000000..ac745b6 --- /dev/null +++ b/test/player-page-playback.test.js @@ -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); +}); \ No newline at end of file diff --git a/test/player-playlist.test.js b/test/player-playlist.test.js index f546404..2c84599 100644 --- a/test/player-playlist.test.js +++ b/test/player-playlist.test.js @@ -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); diff --git a/test/playlist-form-view-model.test.js b/test/playlist-form-view-model.test.js new file mode 100644 index 0000000..ab8f1aa --- /dev/null +++ b/test/playlist-form-view-model.test.js @@ -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); +}); \ No newline at end of file