Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a8b45357 | ||
|
|
f9425fc640 | ||
|
|
4491c15215 |
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
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.6.26 - 2026-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- API sources and RSS feeds now accept hours as an update interval unit, and the list and background task scheduling paths now format and convert that unit correctly.
|
||||||
|
|
||||||
|
## 2.6.25 - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Screen group edit now keeps the slug locked after creation, so existing screen group URLs remain stable.
|
||||||
|
|
||||||
|
## 2.6.24 - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deferred playlist updates now keep the current slide index when the next playlist snapshot is applied, so playback no longer jumps back to the first slide mid-cycle.
|
||||||
|
|
||||||
## 2.6.23 - 2026-08-09
|
## 2.6.23 - 2026-08-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.6.23",
|
"version": "2.6.26",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.6.23",
|
"version": "2.6.26",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.23",
|
"version": "2.6.26",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ const ITEMS_PATH_MAX_LENGTH = 255;
|
|||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
return 'minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAuthMethod(value) {
|
function normalizeAuthMethod(value) {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ const URL_MAX_LENGTH = 1024;
|
|||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
return 'minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRssFeedsData(pool) {
|
async function fetchRssFeedsData(pool) {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
|||||||
if (!pendingPlaylistUpdate) {
|
if (!pendingPlaylistUpdate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
var nextIndex = Number(index || 0);
|
||||||
slides = pendingPlaylistUpdate.slides;
|
slides = pendingPlaylistUpdate.slides;
|
||||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||||
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
|
|||||||
templateLayoutCache = Object.create(null);
|
templateLayoutCache = Object.create(null);
|
||||||
templateRenderPlanCache = Object.create(null);
|
templateRenderPlanCache = Object.create(null);
|
||||||
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
index = 0;
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
if (!Number.isFinite(nextIndex) || nextIndex < 0) {
|
||||||
|
nextIndex = 0;
|
||||||
|
}
|
||||||
|
index = activeSlides.length ? Math.min(nextIndex, activeSlides.length - 1) : 0;
|
||||||
logDebug('Applied updated playlist on slide transition.');
|
logDebug('Applied updated playlist on slide transition.');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,15 @@ function refresh() {
|
|||||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||||
|
if (window.initialData && typeof window.initialData === 'object') {
|
||||||
|
window.initialData.screen = data.screen || window.initialData.screen || null;
|
||||||
|
window.initialData.playlist = data.playlist || null;
|
||||||
|
window.initialData.slides = nextSlides;
|
||||||
|
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
|
||||||
|
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
|
||||||
|
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
|
||||||
|
window.initialData.revision = nextSignature;
|
||||||
|
}
|
||||||
savePlaylistSnapshot({
|
savePlaylistSnapshot({
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ function normalizeIntervalMs(value, unit) {
|
|||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return numericValue * 1000;
|
return numericValue * 1000;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return numericValue * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
return numericValue * 60 * 1000;
|
return numericValue * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return text.slice(0, limit);
|
return text.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value) {
|
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExplicitPlayerBaseUrl(value) {
|
function normalizeExplicitPlayerBaseUrl(value) {
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
@@ -246,39 +242,24 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||||
return res.status(400).send('A screen with that name already exists.');
|
return res.status(400).send('A screen with that name already exists.');
|
||||||
}
|
}
|
||||||
const slugInput = typeof common.validateMaxLength === 'function'
|
|
||||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
|
||||||
: readLimitedText(req.body.slug || '', 255);
|
|
||||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
const slug = String(screen.slug || '').trim();
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
const previousSlug = String(screen.slug || '').trim();
|
||||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
}
|
}
|
||||||
if (previousSlug && previousSlug !== slug) {
|
if (previousSlug && previousSlug !== slug) {
|
||||||
const previousScreenTargets = await pool.query(
|
const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
|
||||||
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
|
const redirectPayload = {
|
||||||
FROM d_screens s
|
|
||||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
|
||||||
WHERE s.slug = ?
|
|
||||||
LIMIT 1`,
|
|
||||||
[previousSlug]
|
|
||||||
);
|
|
||||||
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
|
|
||||||
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
|
|
||||||
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
|
|
||||||
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
|
||||||
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
|
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
url: `/screen/${encodeURIComponent(slug)}`
|
||||||
});
|
};
|
||||||
|
if (previousConnections.length) {
|
||||||
|
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
|
||||||
} else {
|
} else {
|
||||||
await forwardPlayerCommand(previousSlug, {
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
command: 'redirect',
|
|
||||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ function toIsoTimestamp(value) {
|
|||||||
|
|
||||||
function formatIntervalLabel(interval, unit) {
|
function formatIntervalLabel(interval, unit) {
|
||||||
const value = Math.max(1, Number(interval) || 0);
|
const value = Math.max(1, Number(interval) || 0);
|
||||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||||
|
}
|
||||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
|||||||
itemsPath: apiSource.items_path || '',
|
itemsPath: apiSource.items_path || '',
|
||||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||||
|
: apiSource.update_interval_unit === 'hours'
|
||||||
|
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every hour' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} hours`)
|
||||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ const { renderView } = require('../../../view');
|
|||||||
|
|
||||||
function formatIntervalLabel(interval, unit) {
|
function formatIntervalLabel(interval, unit) {
|
||||||
const value = Math.max(1, Number(interval) || 0);
|
const value = Math.max(1, Number(interval) || 0);
|
||||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||||
|
}
|
||||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||||
|
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||||
|
<option value="hours" {{#if (eq rssFeed.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="screen-slug" class="form-label">Slug</label>
|
<label for="screen-slug" class="form-label">Slug</label>
|
||||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" />
|
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" {{#if isEdit}}disabled aria-disabled="true" title="Slug cannot be changed after creation"{{/if}} />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { buildApiSourcePayload } = require('../src/data/api-sources');
|
||||||
|
const { buildRssFeedPayload } = require('../src/data/rss-feeds');
|
||||||
|
const { normalizeIntervalMs } = require('../src/web/lib/background-tasks/queue');
|
||||||
|
|
||||||
|
test('data source payloads accept hours as an update interval unit', () => {
|
||||||
|
const apiPayload = buildApiSourcePayload({
|
||||||
|
body: {
|
||||||
|
name: 'API source',
|
||||||
|
api_url: 'https://example.com/api',
|
||||||
|
update_interval_value: '2',
|
||||||
|
update_interval_unit: 'hours'
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
const rssPayload = buildRssFeedPayload({
|
||||||
|
body: {
|
||||||
|
name: 'RSS feed',
|
||||||
|
feed_url: 'https://example.com/feed.xml',
|
||||||
|
update_interval_value: '3',
|
||||||
|
update_interval_unit: 'hours',
|
||||||
|
item_limit: '5'
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
assert.equal(apiPayload.updateIntervalUnit, 'hours');
|
||||||
|
assert.equal(apiPayload.updateIntervalValue, 2);
|
||||||
|
assert.equal(rssPayload.updateIntervalUnit, 'hours');
|
||||||
|
assert.equal(rssPayload.updateIntervalValue, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background task interval conversion supports hours', () => {
|
||||||
|
assert.equal(normalizeIntervalMs(2, 'hours'), 7_200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data source forms expose hours as an interval option', () => {
|
||||||
|
const apiTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'api-sources', 'form.hbs'), 'utf8');
|
||||||
|
const rssTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(apiTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||||
|
assert.match(rssTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||||
|
});
|
||||||
@@ -39,6 +39,11 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
pausedRemainingMs: null,
|
pausedRemainingMs: null,
|
||||||
app: null,
|
app: null,
|
||||||
currentPlaylistEtag: '',
|
currentPlaylistEtag: '',
|
||||||
|
initialData: {
|
||||||
|
rssFeeds: [{ id: 1 }],
|
||||||
|
apiSources: [{ id: 2 }],
|
||||||
|
timetableGroups: [{ id: 3 }]
|
||||||
|
},
|
||||||
activeSlidesCacheKey: '',
|
activeSlidesCacheKey: '',
|
||||||
activeSlidesCacheValue: [],
|
activeSlidesCacheValue: [],
|
||||||
renderCacheViewportKey: '',
|
renderCacheViewportKey: '',
|
||||||
@@ -94,6 +99,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
this.responseText = JSON.stringify({
|
this.responseText = JSON.stringify({
|
||||||
signature: 'next-signature',
|
signature: 'next-signature',
|
||||||
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
||||||
|
rssFeeds: [{ id: 10 }],
|
||||||
|
apiSources: [{ id: 20 }],
|
||||||
|
timetableGroups: [{ id: 30 }],
|
||||||
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
||||||
});
|
});
|
||||||
if (typeof this.onreadystatechange === 'function') {
|
if (typeof this.onreadystatechange === 'function') {
|
||||||
@@ -133,6 +141,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
assert.equal(calls.showCurrent, 0);
|
assert.equal(calls.showCurrent, 0);
|
||||||
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
||||||
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
||||||
|
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
|
||||||
|
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
|
||||||
|
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
|
||||||
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
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);
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
||||||
});
|
});
|
||||||
@@ -279,6 +290,122 @@ test('single-slide playlists re-render the active slide instead of refreshing af
|
|||||||
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
|
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deferred playlist updates preserve the current slide index', async () => {
|
||||||
|
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 },
|
||||||
|
{ id: 3, 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() {},
|
||||||
|
sendCommandState() {},
|
||||||
|
logDebug() {}
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
{ id: 2, duration_seconds: 12 },
|
||||||
|
{ id: 3, 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' + script, sandbox, { filename: scriptPath });
|
||||||
|
|
||||||
|
sandbox.pendingPlaylistUpdate = {
|
||||||
|
slides: [
|
||||||
|
{ id: 10, duration_seconds: 12 },
|
||||||
|
{ id: 11, duration_seconds: 12 },
|
||||||
|
{ id: 12, duration_seconds: 12 }
|
||||||
|
],
|
||||||
|
signature: 'next-signature',
|
||||||
|
fadeBetweenSlides: false,
|
||||||
|
skipUnavailableRtmp: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const applied = sandbox.applyPendingPlaylistUpdate();
|
||||||
|
|
||||||
|
assert.equal(applied, true);
|
||||||
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
||||||
|
assert.equal(sandbox.pendingPlaylistUpdate, null);
|
||||||
|
assert.deepEqual(sandbox.slides, [
|
||||||
|
{ id: 10, duration_seconds: 12 },
|
||||||
|
{ id: 11, duration_seconds: 12 },
|
||||||
|
{ id: 12, duration_seconds: 12 }
|
||||||
|
]);
|
||||||
|
assert.equal(sandbox.index, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
|
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
|
||||||
const calls = {
|
const calls = {
|
||||||
showCurrent: 0,
|
showCurrent: 0,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||||
|
|
||||||
test('screen update redirects and forwards redirect when the slug changes', async () => {
|
test('screen update keeps the existing slug on edit', async () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const app = {
|
const app = {
|
||||||
post(path, ...routeHandlers) {
|
post(path, ...routeHandlers) {
|
||||||
@@ -14,37 +14,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
|
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql) {
|
async query(sql) {
|
||||||
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
|
|
||||||
return [[{
|
|
||||||
slug: 'alpha',
|
|
||||||
player_id: 'player-a',
|
|
||||||
public_base_url: 'http://player.local',
|
|
||||||
internal_base_url: 'http://player.internal'
|
|
||||||
}]];
|
|
||||||
}
|
|
||||||
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
||||||
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
||||||
}
|
}
|
||||||
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
|
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?')) {
|
||||||
return [[
|
|
||||||
{
|
|
||||||
slug: 'alpha',
|
|
||||||
player_id: 'player-a',
|
|
||||||
public_base_url: 'http://player-a.example',
|
|
||||||
internal_base_url: 'http://player-a.internal'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'beta',
|
|
||||||
player_id: 'player-b',
|
|
||||||
public_base_url: 'http://player-b.example',
|
|
||||||
internal_base_url: 'http://player-b.internal'
|
|
||||||
}
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
|
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
return [[]];
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +113,6 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
|
|
||||||
assert.equal(res.redirectedTo, '/screens?edit=42');
|
assert.equal(res.redirectedTo, '/screens?edit=42');
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
|
|
||||||
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,22 @@ test('screen edit page includes shared launcher downloads and base player url',
|
|||||||
pages,
|
pages,
|
||||||
buildDashboardState: async () => ({ screens: [] }),
|
buildDashboardState: async () => ({ screens: [] }),
|
||||||
getScreenDeleteBlockMessage: async () => '',
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
getScreenConnections: async () => [],
|
getScreenConnections: async (slug) => {
|
||||||
|
if (slug !== 'demo-conference') {
|
||||||
|
return { connections: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
clientId: 'conn-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
playerPublicBaseUrl: 'http://player.example/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
playerPublicBaseUrl: 'http://player.example',
|
playerPublicBaseUrl: 'http://player.example',
|
||||||
requirePermission() {
|
requirePermission() {
|
||||||
return function (_req, _res, next) {
|
return function (_req, _res, next) {
|
||||||
@@ -265,6 +280,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
|
|||||||
name: 'Demo Conference',
|
name: 'Demo Conference',
|
||||||
slug: 'demo-conference',
|
slug: 'demo-conference',
|
||||||
playlist_id: null,
|
playlist_id: null,
|
||||||
|
slug_update_confirm_live_connection_count: 2,
|
||||||
|
slug_update_confirm_message: 'Are you sure you want to update the slug? This will refresh all screens using this slug.',
|
||||||
player_urls: [
|
player_urls: [
|
||||||
{
|
{
|
||||||
identifier: 'player-alpha',
|
identifier: 'player-alpha',
|
||||||
@@ -283,6 +300,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
|
|||||||
assert.match(html, /card-body table-responsive p-0/);
|
assert.match(html, /card-body table-responsive p-0/);
|
||||||
assert.match(html, /table table-striped w-100 mb-0/);
|
assert.match(html, /table table-striped w-100 mb-0/);
|
||||||
assert.match(html, /player-alpha/);
|
assert.match(html, /player-alpha/);
|
||||||
|
assert.match(html, /<input[^>]+id="screen-slug"[^>]+disabled/);
|
||||||
|
assert.match(html, /Slug cannot be changed after creation/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screen launcher downloads are shared and attached', async () => {
|
test('screen launcher downloads are shared and attached', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user