Release v2.10.6

This commit is contained in:
2026-08-29 14:59:06 +01:00
parent ca9f38f3b9
commit d97ff5bc19
30 changed files with 922 additions and 531 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file.
## 2.10.6 - 2026-08-29
### Changed
- Refactored the player runtime into focused animation, media, transition, command, playback, and rendering modules.
- Improved player slide transitions and video playback by preloading media, preserving precise durations, pausing outgoing videos during crossfades, and deferring expensive post-render setup.
- Added resilient playlist recovery through cached browser and server snapshots, offline status reporting, and refresh handling that recovers after cached responses.
### Fixed
- Fixed remote player commands and media synchronization so they route through the bridge using the physical player device identity, including announcement and playlist refresh notifications.
- Fixed player media handling for remote bridge uploads and deletes by using the bridge endpoint with explicit player-device authentication.
- Fixed player service-worker caching so ranged media requests bypass stale cached responses and video playback remains reliable.
## 2.10.5 - 2026-08-29
### Fixed
- Fixed intermittent pairing failures while player registration and pairing sessions propagate through the bridge by retrying transient resolution and completion requests.
- Fixed the pairing page so transient submission failures retry automatically and pairing progress uses a single spinner without dimming the form.
- Fixed the player onboarding page so it continues polling until the pairing code and QR code are available.
## 2.10.4 - 2026-08-29
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.10.4",
"version": "2.10.6",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.10.4",
"version": "2.10.6",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.10.4",
"version": "2.10.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.10.4",
"version": "2.10.6",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.10.4",
"version": "2.10.6",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
@@ -44,6 +44,22 @@
})
.catch(function () { return null; });
}
function keepPairingSessionLoaded(deviceId, clientId) {
var pairingSessionPoll = null;
function poll() {
loadPairingSession(deviceId, clientId).then(function (payload) {
if (payload && payload.pairingCode) {
loadQr(deviceId, clientId);
if (pairingSessionPoll) {
window.clearInterval(pairingSessionPoll);
pairingSessionPoll = null;
}
}
});
}
poll();
pairingSessionPoll = window.setInterval(poll, 1000);
}
function getClientId() {
var stored = getSessionStorageItem("pulse-signage-player-client-id");
if (stored) { return stored; }
@@ -74,9 +90,7 @@
if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc;
}
loadPairingSession(deviceId, clientId).then(function () {
loadQr(deviceId, clientId);
});
keepPairingSessionLoaded(deviceId, clientId);
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
+6 -3
View File
@@ -42,7 +42,8 @@
let isBlackout = false;
let pausedRemainingMs = null;
let slideExpiresAt = null;
const slideFadeDurationMs = 560;
const slideFadeLengthMs = 560;
const slideFadeOffsetMs = slideFadeLengthMs / 2;
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
const commandClientStorageKey = 'pulse-signage-player-client-id';
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
@@ -64,10 +65,12 @@
function logDebug(message, details, level) {
var logger = level === 'error' ? console.error : console.info;
var timestamp = new Date().toISOString();
var timestampedMessage = '[' + timestamp + '] ' + String(message || '');
if (details) {
logger(message, details);
logger(timestampedMessage, details);
} else {
logger(message);
logger(timestampedMessage);
}
}
@@ -0,0 +1,253 @@
// Region animation configuration and lifecycle helpers.
function normalizePlayerAnimationConfig(value) {
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizePlayerAnimationStep(raw.loop, 'none')
};
}
function normalizePlayerAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
preset: String(value.preset || fallbackPreset || 'none').trim(),
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
};
}
return {
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
duration_ms: null,
delay_ms: null,
iterations: null
};
}
function getAnimationStepTimingMs(step) {
if (!step) {
return 0;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return 0;
}
var durationMs = Number(step.duration_ms);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = 1000;
}
var delayMs = Number(step.delay_ms);
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
var iterations = Number(step.iterations);
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
return delayMs + (durationMs * iterations);
}
function getRegionAnimationPhaseTimings(root, phase) {
if (!root) {
return [];
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var timings = [];
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
if (!element) {
return;
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
if (timingMs > 0) {
timings.push({
element: element,
timingMs: timingMs
});
}
});
return timings;
}
function getRegionAnimationPhaseTimingMs(root, phase) {
return getRegionAnimationPhaseTimings(root, phase).reduce(function (maxTimingMs, entry) {
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
}, 0);
}
function clearRegionAnimationClasses(root) {
if (!root) {
return;
}
var elements = [];
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
elements.push(root);
}
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
elements.push(element);
});
elements.forEach(function (element) {
if (!element) {
return;
}
element.classList.remove('animate__animated', 'animate__infinite');
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
if (String(className || '').indexOf('animate__') === 0) {
element.classList.remove(className);
}
});
element.style.removeProperty('--animate-duration');
element.style.removeProperty('--animate-delay');
element.style.removeProperty('--animate-repeat');
});
}
function isAttentionSeekerAnimation(preset) {
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
}
function applyAnimationStep(element, step, phase) {
if (!element || !step) {
return;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return;
}
element.classList.add('animate__animated', 'animate__' + preset);
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
if (Number(step.delay_ms || 0) > 0) {
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
} else {
element.style.removeProperty('--animate-delay');
}
if (phase === 'loop') {
var repeatCount = Number(step.iterations);
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
repeatCount = 1;
}
if (repeatCount > 1) {
element.classList.add('animate__repeat-1');
}
element.style.setProperty('--animate-repeat', String(repeatCount));
return;
}
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
}
}
function playRegionAnimation(element, phase) {
if (!element) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
element.dataset.animationPhase = normalizedPhase;
clearRegionAnimationClasses(element);
if (normalizedPhase === 'outro') {
applyAnimationStep(element, config.outro, 'outro');
return;
}
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
applyAnimationStep(element, config.intro, 'intro');
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
element.addEventListener('animationend', function handleAnimationEnd(event) {
if (event.target !== element) {
return;
}
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
return;
}
element.removeEventListener('animationend', handleAnimationEnd);
clearRegionAnimationClasses(element);
applyAnimationStep(element, config.loop, 'loop');
});
}
return;
}
applyAnimationStep(element, config.loop, 'loop');
}
function playRegionAnimations(root, phase) {
if (!root) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
elements.forEach(function (element) {
playRegionAnimation(element, normalizedPhase);
});
}
+9 -187
View File
@@ -6,7 +6,6 @@ function getCurrentViewport() {
};
}
var slideOutroTimers = [];
var commandHeartbeatTimer = null;
// Command websocket and player-state helpers.
@@ -62,103 +61,6 @@ function scheduleViewportRenderUpdate() {
}, 150);
}
// Cancel the current slide-advance timer.
function clearSlideTimer() {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
clearSlideOutroTimer();
}
// Cancel any pending outro triggers for the current slide.
function clearSlideOutroTimer() {
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
slideOutroTimers = [];
return;
}
slideOutroTimers.forEach(function (timerId) {
window.clearTimeout(timerId);
});
slideOutroTimers = [];
}
// Return the rendered slide root that is currently on screen.
function getCurrentSlideRoot() {
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
if (shells.length) {
return shells[shells.length - 1];
}
return app && app.firstElementChild ? app.firstElementChild : app;
}
// Schedule the outgoing slide animation for each region so it finishes before removal.
function scheduleSlideOutro(holdDelayMs) {
clearSlideOutroTimer();
var currentRoot = getCurrentSlideRoot();
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
return;
}
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
if (!regionTimings.length) {
return;
}
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
slideOutroTimers = regionTimings.map(function (entry) {
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
return window.setTimeout(function () {
if (!entry || !entry.element || !entry.element.isConnected) {
return;
}
playRegionAnimation(entry.element, 'outro');
}, triggerDelayMs);
});
}
// Schedule the next slide transition.
function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
showCurrent();
return;
}
if (index >= activeSlides.length) {
index = 0;
}
index = (index + 1) % activeSlides.length;
showCurrent();
}, holdDelayMs);
}
// Return the slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs;
}
// Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() {
if (slideTransitionTimer) {
window.clearTimeout(slideTransitionTimer);
slideTransitionTimer = null;
}
}
function getPlayerRegionModules() {
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
return [];
@@ -195,65 +97,11 @@ function initializeRegionInstances(root) {
}
// Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade) {
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
clearSlideTransitionTimer();
destroyRegionInstances(app);
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var startDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
var playbackScheduled = false;
video.autoplay = true;
video.loop = true;
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
function schedulePlaybackStart() {
if (playbackScheduled) {
return;
}
playbackScheduled = true;
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
});
if (typeof destroySlideMedia === 'function') {
destroySlideMedia(app);
}
function schedulePostRenderSetup(root, delayMs) {
@@ -265,13 +113,10 @@ function renderSlideMarkup(markup, shouldFade) {
return;
}
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
initializeRegionInstances(root);
initializeRenderedVideoPlayback(root, delayMs);
if (typeof initializeSlideMedia === 'function') {
initializeSlideMedia(root, delayMs);
}
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
@@ -298,28 +143,6 @@ function renderSlideMarkup(markup, shouldFade) {
});
}
function pauseRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
if (!video || typeof video.pause !== 'function') {
return;
}
window.setTimeout(function () {
try {
video.pause();
} catch (_error) {
// Ignore pause errors from detached or unsupported media elements.
}
}, pauseDelayMs);
});
}
var nextShell = document.createElement('div');
nextShell.className = 'slide-shell slide-shell-entering';
nextShell.style.zIndex = '0';
@@ -332,7 +155,7 @@ function renderSlideMarkup(markup, shouldFade) {
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
return nextShell;
}
@@ -343,7 +166,6 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.classList.add('is-exiting');
previousShell.style.zIndex = '1';
previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell);
window.requestAnimationFrame(function () {
@@ -351,7 +173,7 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.style.opacity = '0';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
});
slideTransitionTimer = window.setTimeout(function () {
@@ -365,7 +187,7 @@ function renderSlideMarkup(markup, shouldFade) {
nextShell.classList.add('is-visible');
}
slideTransitionTimer = null;
}, slideFadeDurationMs);
}, slideFadeLengthMs);
return nextShell;
}
+94
View File
@@ -0,0 +1,94 @@
// Slide media startup and teardown helpers.
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var startDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
var playbackScheduled = false;
video.autoplay = false;
video.loop = !(video.dataset && video.dataset.loop === '0');
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
function schedulePlaybackStart() {
if (playbackScheduled) {
return;
}
playbackScheduled = true;
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
});
}
function pauseRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
if (!video || typeof video.pause !== 'function') {
return;
}
window.setTimeout(function () {
try {
video.pause();
} catch (_error) {
return null;
}
}, pauseDelayMs);
});
}
function initializeSlideMedia(root, delayMs) {
if (!root || root.isConnected === false) {
return;
}
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
initializeRenderedVideoPlayback(root, delayMs);
}
function destroySlideMedia(root) {
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(root);
}
}
+37 -6
View File
@@ -53,18 +53,34 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
index = currentIndex;
var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, !(options && options.skipFade) && currentPlaylistFadeBetweenSlides);
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
sendCommandState(slide);
if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideAdvanceDelay(slide));
}
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true;
}
// Calculate the configured time from slide entry to the next transition.
function getSlideAdvanceDelay(slide) {
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
if (slide && slide.use_video_duration) {
return currentPlaylistFadeBetweenSlides
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
: getSlideHoldDelay(durationMs);
}
if (currentPlaylistFadeBetweenSlides) {
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
}
return getSlideHoldDelay(durationMs);
}
// Promote a deferred playlist update at the next safe point.
function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) {
@@ -167,11 +183,16 @@ function refresh(applyImmediately) {
return;
}
if (request.status === 304) {
if (!initialData || !Array.isArray(initialData.apiSources) || !Array.isArray(initialData.weatherLocations)) {
currentPlaylistEtag = '';
refresh(applyImmediately);
return;
}
logDebug('Playlist refresh completed with no changes.');
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
return;
}
@@ -198,6 +219,7 @@ function refresh(applyImmediately) {
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);
const hadSourceData = !(typeof window !== 'undefined' && window.initialData === null);
var refreshedInitialData = Object.assign({},
typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), {
screen: data.screen || null,
@@ -213,6 +235,11 @@ function refresh(applyImmediately) {
initialData = refreshedInitialData;
}
window.initialData = refreshedInitialData;
if (!hadSourceData) {
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
}
savePlaylistSnapshot({
slides: nextSlides,
signature: nextSignature,
@@ -237,8 +264,12 @@ function refresh(applyImmediately) {
return;
}
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (!hadSourceData) {
showCurrent({ skipFade: true });
return;
}
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
return;
}
@@ -300,7 +331,7 @@ function refresh(applyImmediately) {
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
};
request.ontimeout = function () {
@@ -312,7 +343,7 @@ function refresh(applyImmediately) {
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
};
request.send();
+9 -259
View File
@@ -12,12 +12,10 @@ function normalizeStyleAttributeValue(value) {
.replace(/&amp;#39;/g, "'");
}
// Clamp font size to the supported range.
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
// Validate a text color and fall back when needed.
function sanitizeTextColor(value, fallback) {
var raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
@@ -26,7 +24,6 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000';
}
// Read the template's canvas dimensions with safe defaults.
function getTemplateCanvasSize(template) {
return {
width: Math.max(1, Number(template.canvas_size_width || 1920)),
@@ -34,7 +31,6 @@ function getTemplateCanvasSize(template) {
};
}
// Read the server-supplied playlist revision, or fall back to the ETag.
function getPlaylistRevision(data) {
if (data && data.revision) {
return String(data.revision);
@@ -48,7 +44,6 @@ function getPlaylistRevision(data) {
return String(Date.now());
}
// Scale a canvas to fit within the viewport.
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
@@ -61,53 +56,10 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
};
}
function normalizePlayerAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
preset: String(value.preset || fallbackPreset || 'none').trim(),
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
};
}
return {
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
duration_ms: null,
delay_ms: null,
iterations: null
};
}
function normalizePlayerAnimationConfig(value) {
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizePlayerAnimationStep(raw.loop, 'none')
};
}
function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
var preset = String((config[stepName] && config[stepName].preset) || '').trim();
return preset && preset !== 'none';
}));
}
@@ -122,215 +74,6 @@ function decorateRegionMarkup(markup, region) {
});
}
function clearRegionAnimationClasses(root) {
if (!root) {
return;
}
var elements = [];
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
elements.push(root);
}
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
elements.push(element);
});
elements.forEach(function (element) {
if (!element) {
return;
}
element.classList.remove('animate__animated', 'animate__infinite');
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
if (String(className || '').indexOf('animate__') === 0) {
element.classList.remove(className);
}
});
element.style.removeProperty('--animate-duration');
element.style.removeProperty('--animate-delay');
element.style.removeProperty('--animate-repeat');
});
}
function isAttentionSeekerAnimation(preset) {
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
}
function applyAnimationStep(element, step, phase) {
if (!element || !step) {
return;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return;
}
element.classList.add('animate__animated', 'animate__' + preset);
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
if (Number(step.delay_ms || 0) > 0) {
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
} else {
element.style.removeProperty('--animate-delay');
}
if (phase === 'loop') {
var repeatCount = Number(step.iterations);
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
repeatCount = 1;
}
if (repeatCount > 1) {
element.classList.add('animate__repeat-1');
}
element.style.setProperty('--animate-repeat', String(repeatCount));
return;
}
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
}
}
function getAnimationStepTimingMs(step) {
if (!step) {
return 0;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return 0;
}
var durationMs = Number(step.duration_ms);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = 1000;
}
var delayMs = Number(step.delay_ms);
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
var iterations = Number(step.iterations);
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
return delayMs + (durationMs * iterations);
}
function getRegionAnimationPhaseTimingMs(root, phase) {
var timings = getRegionAnimationPhaseTimings(root, phase);
return timings.reduce(function (maxTimingMs, entry) {
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
}, 0);
}
function getRegionAnimationPhaseTimings(root, phase) {
if (!root) {
return [];
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var timings = [];
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
if (!element) {
return;
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
if (timingMs > 0) {
timings.push({
element: element,
timingMs: timingMs
});
}
});
return timings;
}
function playRegionAnimation(element, phase) {
if (!element) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
element.dataset.animationPhase = normalizedPhase;
clearRegionAnimationClasses(element);
if (normalizedPhase === 'outro') {
applyAnimationStep(element, config.outro, 'outro');
return;
}
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
applyAnimationStep(element, config.intro, 'intro');
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
element.addEventListener('animationend', function handleAnimationEnd(event) {
if (event.target !== element) {
return;
}
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
return;
}
element.removeEventListener('animationend', handleAnimationEnd);
clearRegionAnimationClasses(element);
applyAnimationStep(element, config.loop, 'loop');
});
}
return;
}
applyAnimationStep(element, config.loop, 'loop');
}
function playRegionAnimations(root, phase) {
if (!root) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
elements.forEach(function (element) {
playRegionAnimation(element, normalizedPhase);
});
}
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) {
return;
@@ -600,6 +343,13 @@ function normalizeSlide(slide) {
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
if (normalized.use_video_duration && normalized.template && Array.isArray(normalized.template.regions)) {
normalized.template.regions.forEach(function (region) {
if (region && region.region_type === 'video' && normalized.content[region.region_key]) {
normalized.content[region.region_key].loop = false;
}
});
}
return normalized;
}
@@ -0,0 +1,104 @@
// Slide transition timing, cancellation, and outgoing animation coordination.
var slideOutroTimers = [];
// Cancel the current slide-advance timer.
function clearSlideTimer() {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
clearSlideOutroTimer();
}
// Cancel any pending outro triggers for the current slide.
function clearSlideOutroTimer() {
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
slideOutroTimers = [];
return;
}
slideOutroTimers.forEach(function (timerId) {
window.clearTimeout(timerId);
});
slideOutroTimers = [];
}
// Return the rendered slide root that is currently on screen.
function getCurrentSlideRoot() {
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
if (shells.length) {
return shells[shells.length - 1];
}
return app && app.firstElementChild ? app.firstElementChild : app;
}
// Schedule the outgoing slide animation for each region so it finishes before removal.
function scheduleSlideOutro(holdDelayMs) {
clearSlideOutroTimer();
var currentRoot = getCurrentSlideRoot();
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
return;
}
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
if (!regionTimings.length) {
return;
}
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
slideOutroTimers = regionTimings.map(function (entry) {
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
return window.setTimeout(function () {
if (!entry || !entry.element || !entry.element.isConnected) {
return;
}
playRegionAnimation(entry.element, 'outro');
}, triggerDelayMs);
});
}
// Schedule the next slide transition.
function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
showCurrent();
return;
}
if (index >= activeSlides.length) {
index = 0;
}
index = (index + 1) % activeSlides.length;
showCurrent();
}, holdDelayMs);
}
// Return the configured slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) {
return Math.max(1, Number(delayMs || 0));
}
// Calculate the configured time from slide entry to the next transition.
function getSlideAdvanceDelay(slide) {
return getSlideHoldDelay(Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000);
}
// Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() {
if (slideTransitionTimer) {
window.clearTimeout(slideTransitionTimer);
slideTransitionTimer = null;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Service worker cache strategy for player pages, assets, media, and playlists.
const CACHE_VERSION = 'v38';
const CACHE_VERSION = new URL(self.location.href).searchParams.get('v') || 'development';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
+5 -3
View File
@@ -112,6 +112,8 @@ function renderVideoRegion(region, regionContent) {
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
var shouldLoop = !(regionContent && regionContent.loop === false);
var loopMarkup = shouldLoop ? ' loop' : '';
if (!requestedSrc) {
return '';
@@ -122,7 +124,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" data-loop="' + (shouldLoop ? '1' : '0') + '"' + (disableAudio ? ' muted' : '') + loopMarkup + ' playsinline preload="auto" disablepictureinpicture></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -132,7 +134,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" data-loop="' + (shouldLoop ? '1' : '0') + '"' + (disableAudio ? ' muted' : '') + loopMarkup + ' playsinline preload="auto" disablepictureinpicture></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -141,7 +143,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" data-loop="' + (shouldLoop ? '1' : '0') + '"' + (disableAudio ? ' muted' : '') + loopMarkup + ' playsinline preload="auto" disablepictureinpicture></video></div>';
}
return '';
+6
View File
@@ -347,6 +347,9 @@ const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html')
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
const playerPageAnimationScriptPath = path.join(__dirname, 'public', 'js', 'player-page-animation.js');
const playerPageMediaScriptPath = path.join(__dirname, 'public', 'js', 'player-page-media.js');
const playerPageTransitionScriptPath = path.join(__dirname, 'public', 'js', 'player-page-transition.js');
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
@@ -540,6 +543,9 @@ function getPlayerRuntimeScripts() {
['client-name', getScriptBody(getPlayerClientNameScript()())],
['offline', getScriptBody(getPlayerPageOfflineScript()())],
['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
['animation', fs.readFileSync(playerPageAnimationScriptPath, 'utf8').trim()],
['media', fs.readFileSync(playerPageMediaScriptPath, 'utf8').trim()],
['transition', fs.readFileSync(playerPageTransitionScriptPath, 'utf8').trim()],
['commands', getScriptBody(getPlayerPageCommandsScript()())],
['rendering', getScriptBody(getPlayerPageRenderingScript()())],
['playback', getScriptBody(getPlayerPagePlaybackScript()())],
+4 -2
View File
@@ -2,6 +2,7 @@
const Handlebars = require('handlebars');
const path = require('path');
const packageMetadata = require('../../package.json');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
@@ -22,11 +23,12 @@ function renderPage(template, options) {
}
function getPlayerServiceWorkerRegistrationScript() {
const releaseVersion = encodeURIComponent(String(packageMetadata.version || 'development'));
return [
'<script>',
' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
' navigator.serviceWorker.register("/sw.js?v=' + releaseVersion + '").catch(function () {',
' return null;',
' });',
' });',
@@ -120,7 +122,7 @@ function renderPlayerPage(slug, initialData) {
REGION_SCRIPTS: ''
});
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js"></script>';
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js?v=' + encodeURIComponent(String(packageMetadata.version || 'development')) + '"></script>';
}).join('');
return renderPage(template, {
+15 -1
View File
@@ -83,7 +83,7 @@ function registerPlayerRoutes(app, options) {
if (requestHeaders['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim();
}
if (requestHeaders['if-none-match']) {
if (requestHeaders['if-none-match'] && !requestOptions.skipIfNoneMatch) {
headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
}
if (requestHeaders['x-pulse-client-id']) {
@@ -495,8 +495,22 @@ function registerPlayerRoutes(app, options) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
const cachedSnapshot = await readPlaylistSnapshot(req.params.slug);
if (cachedSnapshot) {
return res.end();
}
const refreshedResponse = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET',
skipIfNoneMatch: true
});
const refreshedData = await readJsonResponse(refreshedResponse);
if (!refreshedResponse || refreshedResponse.status >= 400 || !refreshedData) {
return res.status(502).json({ error: 'Thin client playlist cache unavailable.' });
}
await writePlaylistSnapshot(req.params.slug, refreshedData);
return res.json(refreshedData);
}
res.type(response.headers.get('content-type') || 'application/json');
const responseText = await response.text();
try {
+2 -1
View File
@@ -71,7 +71,8 @@ async function start() {
playerActionService.forwardPlayerCommand,
playerActionService.getScreenConnections,
playerActionService.forwardPlayerCommandToBaseUrl,
playerActionService.resolvePlayerBaseUrlForPublicUrl
playerActionService.resolvePlayerBaseUrlForPublicUrl,
playerActionService.forwardPlayerCommandToDevice
);
// Centralized dashboard/player bootstrap.
+1
View File
@@ -121,6 +121,7 @@ function createWebBootstrap(options) {
pool: pool,
common: common,
playerInternalBaseUrl: configuredPlayerInternalUrl,
bridgeInternalBaseUrl: configuredBridgeInternalUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
+13 -18
View File
@@ -36,22 +36,6 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
const deviceId = String(playerIdentifier || '').trim();
if (!targetBaseUrl || !deviceId) {
return targetBaseUrl;
}
try {
const url = new URL(targetBaseUrl);
url.searchParams.set('deviceId', deviceId);
return url.toString().replace(/\/$/, '');
} catch (_error) {
return targetBaseUrl;
}
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
@@ -68,6 +52,7 @@ function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -545,6 +530,9 @@ function createUploadSyncService(options) {
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) {
@@ -566,11 +554,13 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer
});
const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const mediaUploadUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`;
const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim();
const response = await fetch(mediaUploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders
},
body: fileBuffer
@@ -601,6 +591,9 @@ function createUploadSyncService(options) {
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return false;
@@ -610,11 +603,13 @@ function createUploadSyncService(options) {
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}`
});
const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const mediaDeleteUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`;
const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim();
const response = await fetch(mediaDeleteUrl, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders
}
});
+20 -2
View File
@@ -1,6 +1,6 @@
// Route screen refresh commands to the physical player hosting each screen.
function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) {
function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl, forwardPlayerCommandToDevice) {
if (typeof forwardPlayerCommand !== 'function') {
throw new Error('createNotifyPlayerScreens requires a player command sender.');
}
@@ -19,17 +19,35 @@ function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, f
if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') {
const screenState = await getScreenConnections(slug);
const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : [];
const deviceIds = Array.from(new Set(connections.map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
const playerBaseUrls = Array.from(new Set(connections.map(function (connection) {
if (String(connection && connection.playerDeviceId || '').trim()) {
return '';
}
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
const deviceResults = deviceIds.length && typeof forwardPlayerCommandToDevice === 'function'
? await Promise.all(deviceIds.map(function (deviceId) {
return forwardPlayerCommandToDevice(deviceId, {
command: commandOrPayload || 'refresh',
screenSlug: slug
});
}))
: [];
let baseUrlResults = [];
if (playerBaseUrls.length) {
const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); }))
: playerBaseUrls;
return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
baseUrlResults = await Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh');
}));
}
if (deviceResults.length || baseUrlResults.length) {
return deviceResults.concat(baseUrlResults);
}
}
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) {
+17
View File
@@ -243,6 +243,23 @@ function createPlayerActionService(options) {
}
async function forwardAnnouncementRefresh(slug) {
try {
const screenState = await getScreenConnections(slug);
const deviceIds = Array.from(new Set((screenState && Array.isArray(screenState.connections) ? screenState.connections : []).map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
if (deviceIds.length) {
const deviceResults = await Promise.all(deviceIds.map(function (deviceId) {
return forwardPlayerCommandToDevice(deviceId, {
command: 'announcement-refresh',
screenSlug: slug
});
}));
return deviceResults;
}
} catch (_error) {
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([
resolvedPlayerInternalBaseUrl,
-4
View File
@@ -901,10 +901,6 @@
padding: 0.75rem 1rem;
}
.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) {
opacity: 0.55;
}
.onboarding-pairing-progress {
display: flex;
align-items: center;
+26 -13
View File
@@ -44,6 +44,27 @@
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
scannerDebugOutput.textContent = lines.slice(-8).join('\n');
}
function wait(milliseconds) {
return new Promise(function (resolve) { window.setTimeout(resolve, milliseconds); });
}
function submitPairing(body, attempt) {
return fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
var transientCodeError = response.status === 401 && payload && String(payload.error || '').indexOf('valid kiosk pairing code') !== -1;
var transientTransportError = [502, 503, 504].indexOf(response.status) !== -1;
if ((!response.ok && (transientCodeError || transientTransportError)) && attempt < 2) {
return wait(300 * (attempt + 1)).then(function () { return submitPairing(body, attempt + 1); });
}
return { response: response, payload: payload };
});
});
}
var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
function normalizeCode(value) {
@@ -321,28 +342,21 @@
if (firstEmptyInput) { firstEmptyInput.focus(); }
return;
}
message.className = 'alert alert-info';
message.textContent = 'Pairing player...';
message.className = 'alert d-none';
message.textContent = '';
var pairingBody = new URLSearchParams(new FormData(form)).toString();
if (pairingCard) { pairingCard.classList.add('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: pairingBody
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
submitPairing(pairingBody, 0).then(function (result) {
var response = result.response;
var payload = result.payload;
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
}
if (payload && payload.queued) {
message.className = 'alert alert-info';
message.textContent = 'Pairing is still being completed. Keep this page open and wait for confirmation.';
return;
}
message.className = 'alert alert-success';
@@ -363,7 +377,6 @@
}
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
if (manualButton) { manualButton.classList.remove('d-none'); }
});
}).catch(function (error) {
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
+36 -6
View File
@@ -4,6 +4,12 @@ const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
function wait(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}
module.exports = function registerOnboardingRoutes(app, deps) {
const pool = deps.pool;
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, '');
@@ -42,11 +48,19 @@ module.exports = function registerOnboardingRoutes(app, deps) {
{
const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
let resolveResponse = null;
let resolveBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
});
const resolveBody = await resolveResponse.text();
resolveBody = await resolveResponse.text();
if (resolveResponse.status !== 401 || resolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
let resolved = null;
try { resolved = JSON.parse(resolveBody); } catch (_error) {}
if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
@@ -61,11 +75,19 @@ module.exports = function registerOnboardingRoutes(app, deps) {
for (const remoteRegistration of remoteRegistrations) {
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
let bridgeResolveResponse = null;
let bridgeResolveBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
});
const bridgeResolveBody = await bridgeResolveResponse.text();
bridgeResolveBody = await bridgeResolveResponse.text();
if (bridgeResolveResponse.status !== 401 || bridgeResolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
break;
@@ -86,12 +108,20 @@ module.exports = function registerOnboardingRoutes(app, deps) {
}
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload });
const response = await fetch(`${targetBaseUrl}/api/onboarding`, {
let response = null;
let responseBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
response = await fetch(`${targetBaseUrl}/api/onboarding`, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload)
});
const responseBody = await response.text();
responseBody = await response.text();
if (response.status !== 401 || responseBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
} catch (error) {
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' });
+34
View File
@@ -33,3 +33,37 @@ test('playlist notifications use the player URL reported by the screen connectio
command: 'refresh'
}]);
});
test('playlist notifications use the bridge device route for remote player connections', async () => {
const calls = [];
const notify = createNotifyPlayerScreens(
async function () {
calls.push({ type: 'fallback' });
return { ok: true };
},
async function () {
return {
connections: [{ playerDeviceId: 'remote-player-1', playerPublicBaseUrl: 'https://remote.example' }]
};
},
async function () {
calls.push({ type: 'base-url' });
return { ok: true };
},
async function () {
return 'http://player:8081';
},
async function (deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
}
);
const count = await notify(['remote-screen'], 'refresh');
assert.equal(count, 1);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: { command: 'refresh', screenSlug: 'remote-screen' }
}]);
});
+12 -2
View File
@@ -5,9 +5,15 @@ const test = require('node:test');
const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) {
const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js');
const mediaPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-media.js');
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const animationScript = fs.readFileSync(animationPath, 'utf8');
const mediaScript = fs.readFileSync(mediaPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
vm.runInNewContext(animationScript + '\n' + mediaScript + '\n' + transitionScript + '\n' + commandsScript, sandbox, { filename: commandsPath });
}
function createSandbox() {
@@ -77,7 +83,8 @@ function createSandbox() {
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
slideTransitionTimer: null,
slideFadeDurationMs: 560,
slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
slideExpiresAt: null,
pausedRemainingMs: null,
timer: null,
@@ -119,6 +126,9 @@ function createSandbox() {
test('renderSlideMarkup runs post-render setup immediately', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox);
sandbox.playRegionAnimations = function () {
calls.playRegionAnimations += 1;
};
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
+122 -1
View File
@@ -149,6 +149,125 @@ test('playlist refresh queues updates until the next slide transition', async ()
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
});
test('playlist refresh reloads source data after a cached snapshot receives 304', async () => {
const requests = [];
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
slug: 'test',
initialData: null,
currentPlaylistEtag: '"cached-etag"',
currentPlaylistSignature: 'cached-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 10 }],
lastRenderedSlide: null,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
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() {},
sendCommandState() {},
scheduleSlideAdvance() {},
logDebug() {}
};
sandbox.window = sandbox;
class XhrStub {
open(method, url) {
this.method = method;
this.url = url;
requests.push(this);
}
setRequestHeader(name, value) {
this.headers = this.headers || {};
this.headers[name] = value;
}
getResponseHeader() { return ''; }
send() {
this.readyState = 4;
if (requests.length === 1) {
this.status = 304;
this.responseText = '';
} else {
this.status = 200;
this.responseText = JSON.stringify({
signature: 'fresh-signature',
slides: [{ id: 1, duration_seconds: 10 }],
rssFeeds: [],
apiSources: [{ id: 1 }],
timetableGroups: [],
weatherLocations: [{ id: 1 }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
}
this.onreadystatechange();
}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
await sandbox.refresh();
assert.equal(requests.length, 2);
assert.equal(requests[1].headers && requests[1].headers['If-None-Match'], undefined);
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 1 }]);
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
});
test('centralized slide advance timing preserves the configured slide duration', () => {
const sandbox = {
window: null,
currentPlaylistFadeBetweenSlides: false,
slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
getSlideHoldDelay(value) {
return value;
}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 12000);
sandbox.currentPlaylistFadeBetweenSlides = true;
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10 }), 9720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10, use_video_duration: true }), 9440);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 11720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
});
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
const calls = {
showCurrent: 0,
@@ -247,8 +366,10 @@ test('single-slide playlists re-render the active slide instead of refreshing af
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
@@ -270,7 +391,7 @@ test('single-slide playlists re-render the active slide instead of refreshing af
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
vm.runInNewContext(prelude + '\n' + transitionScript + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
sandbox.showCurrent = function () {
calls.showCurrent += 1;
};
+41 -3
View File
@@ -99,7 +99,8 @@ test('fqdn player registration wins over a local configured player target for me
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote');
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
@@ -453,9 +454,46 @@ test('slide update sync queues one media task per live player and targets each p
assert.deepEqual(fetchCalls.map(function (call) {
return call.url;
}).sort(), [
'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one',
'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two'
'http://player-one:8081/api/media/uploads%2Fsample.bin',
'http://player-two:8081/api/media/uploads%2Fsample.bin'
].sort());
assert.deepEqual(fetchCalls.map(function (call) {
return call.init.headers['x-pulse-player-device-id'];
}).sort(), ['player-one', 'player-two']);
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('remote media sync uses the bridge device route', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-bridge-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return { ok: true, status: 200, statusText: 'OK', headers: { get() { return null; } }, async text() { return ''; } };
};
const uploadSyncService = createUploadSyncService({
common: {},
bridgeInternalBaseUrl: 'http://player-bridge:8090',
pool: {
async query() {
return [[{ identifier: 'player-remote', internal_base_url: 'https://remote-player.example', last_seen_at: activeLastSeenAt }]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
assert.equal(await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir), true);
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });