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. 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 ## 2.10.4 - 2026-08-29
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.10.4", "version": "2.10.6",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.10.4", "version": "2.10.6",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.4", "version": "2.10.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.4", "version": "2.10.6",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.4", "version": "2.10.6",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
@@ -44,6 +44,22 @@
}) })
.catch(function () { return null; }); .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() { function getClientId() {
var stored = getSessionStorageItem("pulse-signage-player-client-id"); var stored = getSessionStorageItem("pulse-signage-player-client-id");
if (stored) { return stored; } if (stored) { return stored; }
@@ -74,9 +90,7 @@
if (qr && !qr.getAttribute("src")) { if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc; qr.src = qrPlaceholderSrc;
} }
loadPairingSession(deviceId, clientId).then(function () { keepPairingSessionLoaded(deviceId, clientId);
loadQr(deviceId, clientId);
});
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000); window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
}); });
}()); }());
+6 -3
View File
@@ -42,7 +42,8 @@
let isBlackout = false; let isBlackout = false;
let pausedRemainingMs = null; let pausedRemainingMs = null;
let slideExpiresAt = null; let slideExpiresAt = null;
const slideFadeDurationMs = 560; const slideFadeLengthMs = 560;
const slideFadeOffsetMs = slideFadeLengthMs / 2;
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug); const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
const commandClientStorageKey = 'pulse-signage-player-client-id'; const commandClientStorageKey = 'pulse-signage-player-client-id';
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug; const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
@@ -64,10 +65,12 @@
function logDebug(message, details, level) { function logDebug(message, details, level) {
var logger = level === 'error' ? console.error : console.info; var logger = level === 'error' ? console.error : console.info;
var timestamp = new Date().toISOString();
var timestampedMessage = '[' + timestamp + '] ' + String(message || '');
if (details) { if (details) {
logger(message, details); logger(timestampedMessage, details);
} else { } 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; var commandHeartbeatTimer = null;
// Command websocket and player-state helpers. // Command websocket and player-state helpers.
@@ -62,103 +61,6 @@ function scheduleViewportRenderUpdate() {
}, 150); }, 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() { function getPlayerRegionModules() {
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') { if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
return []; return [];
@@ -195,65 +97,11 @@ function initializeRegionInstances(root) {
} }
// Swap slide markup with optional fade animation. // Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade) { function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
clearSlideTransitionTimer(); clearSlideTransitionTimer();
destroyRegionInstances(app); destroyRegionInstances(app);
if (typeof destroyRtmpRegions === 'function') { if (typeof destroySlideMedia === 'function') {
destroyRtmpRegions(app); destroySlideMedia(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 });
});
} }
function schedulePostRenderSetup(root, delayMs) { function schedulePostRenderSetup(root, delayMs) {
@@ -265,13 +113,10 @@ function renderSlideMarkup(markup, shouldFade) {
return; return;
} }
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
initializeRegionInstances(root); initializeRegionInstances(root);
if (typeof initializeSlideMedia === 'function') {
initializeRenderedVideoPlayback(root, delayMs); initializeSlideMedia(root, delayMs);
}
if (typeof playRegionAnimations === 'function') { if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro'); 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'); var nextShell = document.createElement('div');
nextShell.className = 'slide-shell slide-shell-entering'; nextShell.className = 'slide-shell slide-shell-entering';
nextShell.style.zIndex = '0'; nextShell.style.zIndex = '0';
@@ -332,7 +155,7 @@ function renderSlideMarkup(markup, shouldFade) {
nextShell.classList.remove('slide-shell-entering'); nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible'); nextShell.classList.add('is-visible');
app.appendChild(nextShell); app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2); schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
return nextShell; return nextShell;
} }
@@ -343,7 +166,6 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.classList.add('is-exiting'); previousShell.classList.add('is-exiting');
previousShell.style.zIndex = '1'; previousShell.style.zIndex = '1';
previousShell.style.opacity = '1'; previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell); app.appendChild(nextShell);
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
@@ -351,7 +173,7 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.style.opacity = '0'; previousShell.style.opacity = '0';
nextShell.classList.remove('slide-shell-entering'); nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible'); nextShell.classList.add('is-visible');
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2); schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
}); });
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
@@ -365,7 +187,7 @@ function renderSlideMarkup(markup, shouldFade) {
nextShell.classList.add('is-visible'); nextShell.classList.add('is-visible');
} }
slideTransitionTimer = null; slideTransitionTimer = null;
}, slideFadeDurationMs); }, slideFadeLengthMs);
return nextShell; 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; index = currentIndex;
var markup = buildSlideMarkup(slide); 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') { if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex); scheduleSlideMarkupPreload(availableSlides, currentIndex);
} }
sendCommandState(slide); sendCommandState(slide);
if (!isPaused) { if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideAdvanceDelay(slide));
} }
lastRenderedViewKey = getCurrentRenderKey(availableSlides); lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true; 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. // Promote a deferred playlist update at the next safe point.
function applyPendingPlaylistUpdate() { function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) { if (!pendingPlaylistUpdate) {
@@ -167,11 +183,16 @@ function refresh(applyImmediately) {
return; return;
} }
if (request.status === 304) { 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.'); logDebug('Playlist refresh completed with no changes.');
markRefreshHealthy(); markRefreshHealthy();
setOfflineBannerVisible(false); setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
} }
return; return;
} }
@@ -198,6 +219,7 @@ function refresh(applyImmediately) {
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);
const hadSourceData = !(typeof window !== 'undefined' && window.initialData === null);
var refreshedInitialData = Object.assign({}, var refreshedInitialData = Object.assign({},
typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), { typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), {
screen: data.screen || null, screen: data.screen || null,
@@ -213,6 +235,11 @@ function refresh(applyImmediately) {
initialData = refreshedInitialData; initialData = refreshedInitialData;
} }
window.initialData = refreshedInitialData; window.initialData = refreshedInitialData;
if (!hadSourceData) {
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
}
savePlaylistSnapshot({ savePlaylistSnapshot({
slides: nextSlides, slides: nextSlides,
signature: nextSignature, signature: nextSignature,
@@ -237,8 +264,12 @@ function refresh(applyImmediately) {
return; return;
} }
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) { if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (!hadSourceData) {
showCurrent({ skipFade: true });
return;
}
if (currentActiveSlides.length < 2 && lastRenderedSlide) { if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
} }
return; return;
} }
@@ -300,7 +331,7 @@ function refresh(applyImmediately) {
setOfflineBannerVisible(true); setOfflineBannerVisible(true);
scheduleRefreshRetry(); scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
} }
}; };
request.ontimeout = function () { request.ontimeout = function () {
@@ -312,7 +343,7 @@ function refresh(applyImmediately) {
setOfflineBannerVisible(true); setOfflineBannerVisible(true);
scheduleRefreshRetry(); scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
} }
}; };
request.send(); request.send();
+9 -259
View File
@@ -12,12 +12,10 @@ function normalizeStyleAttributeValue(value) {
.replace(/&amp;#39;/g, "'"); .replace(/&amp;#39;/g, "'");
} }
// Clamp font size to the supported range.
function sanitizeFontSize(value) { function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24); return Math.max(8, Number(value || 0) || 24);
} }
// Validate a text color and fall back when needed.
function sanitizeTextColor(value, fallback) { function sanitizeTextColor(value, fallback) {
var raw = String(value || '').trim(); var raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { 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'; return fallback || '#000000';
} }
// Read the template's canvas dimensions with safe defaults.
function getTemplateCanvasSize(template) { function getTemplateCanvasSize(template) {
return { return {
width: Math.max(1, Number(template.canvas_size_width || 1920)), 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) { function getPlaylistRevision(data) {
if (data && data.revision) { if (data && data.revision) {
return String(data.revision); return String(data.revision);
@@ -48,7 +44,6 @@ function getPlaylistRevision(data) {
return String(Date.now()); return String(Date.now());
} }
// Scale a canvas to fit within the viewport.
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) { function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
var width = Math.max(1, Number(canvasWidth || 0) || 1920); var width = Math.max(1, Number(canvasWidth || 0) || 1920);
var height = Math.max(1, Number(canvasHeight || 0) || 1080); 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) { function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) { 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) { function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) { if (!document || !document.documentElement) {
return; return;
@@ -600,6 +343,13 @@ function normalizeSlide(slide) {
Object.keys(content).forEach(function (regionKey) { Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[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; 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. // 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 PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`; const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${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 cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust); var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio); 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) { if (!requestedSrc) {
return ''; return '';
@@ -122,7 +124,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
setVideoSourceAvailability(requestedSrc, true); 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); var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -132,7 +134,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) { if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; 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); scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -141,7 +143,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) { if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + 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 ''; 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 playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js'); const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.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 playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js'); const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js'); const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
@@ -540,6 +543,9 @@ function getPlayerRuntimeScripts() {
['client-name', getScriptBody(getPlayerClientNameScript()())], ['client-name', getScriptBody(getPlayerClientNameScript()())],
['offline', getScriptBody(getPlayerPageOfflineScript()())], ['offline', getScriptBody(getPlayerPageOfflineScript()())],
['playlist', getScriptBody(getPlayerPagePlaylistScript()())], ['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
['animation', fs.readFileSync(playerPageAnimationScriptPath, 'utf8').trim()],
['media', fs.readFileSync(playerPageMediaScriptPath, 'utf8').trim()],
['transition', fs.readFileSync(playerPageTransitionScriptPath, 'utf8').trim()],
['commands', getScriptBody(getPlayerPageCommandsScript()())], ['commands', getScriptBody(getPlayerPageCommandsScript()())],
['rendering', getScriptBody(getPlayerPageRenderingScript()())], ['rendering', getScriptBody(getPlayerPageRenderingScript()())],
['playback', getScriptBody(getPlayerPagePlaybackScript()())], ['playback', getScriptBody(getPlayerPagePlaybackScript()())],
+4 -2
View File
@@ -2,6 +2,7 @@
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const path = require('path'); const path = require('path');
const packageMetadata = require('../../package.json');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers'); const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth'); const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
@@ -22,11 +23,12 @@ function renderPage(template, options) {
} }
function getPlayerServiceWorkerRegistrationScript() { function getPlayerServiceWorkerRegistrationScript() {
const releaseVersion = encodeURIComponent(String(packageMetadata.version || 'development'));
return [ return [
'<script>', '<script>',
' if ("serviceWorker" in navigator) {', ' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {', ' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {', ' navigator.serviceWorker.register("/sw.js?v=' + releaseVersion + '").catch(function () {',
' return null;', ' return null;',
' });', ' });',
' });', ' });',
@@ -120,7 +122,7 @@ function renderPlayerPage(slug, initialData) {
REGION_SCRIPTS: '' REGION_SCRIPTS: ''
}); });
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) { 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(''); }).join('');
return renderPage(template, { return renderPage(template, {
+15 -1
View File
@@ -83,7 +83,7 @@ function registerPlayerRoutes(app, options) {
if (requestHeaders['x-pulse-page-auth']) { if (requestHeaders['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim(); 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(); headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
} }
if (requestHeaders['x-pulse-client-id']) { if (requestHeaders['x-pulse-client-id']) {
@@ -495,8 +495,22 @@ function registerPlayerRoutes(app, options) {
res.set('Cache-Control', cacheControl); res.set('Cache-Control', cacheControl);
} }
if (response.status === 304) { if (response.status === 304) {
const cachedSnapshot = await readPlaylistSnapshot(req.params.slug);
if (cachedSnapshot) {
return res.end(); 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'); res.type(response.headers.get('content-type') || 'application/json');
const responseText = await response.text(); const responseText = await response.text();
try { try {
+2 -1
View File
@@ -71,7 +71,8 @@ async function start() {
playerActionService.forwardPlayerCommand, playerActionService.forwardPlayerCommand,
playerActionService.getScreenConnections, playerActionService.getScreenConnections,
playerActionService.forwardPlayerCommandToBaseUrl, playerActionService.forwardPlayerCommandToBaseUrl,
playerActionService.resolvePlayerBaseUrlForPublicUrl playerActionService.resolvePlayerBaseUrlForPublicUrl,
playerActionService.forwardPlayerCommandToDevice
); );
// Centralized dashboard/player bootstrap. // Centralized dashboard/player bootstrap.
+1
View File
@@ -121,6 +121,7 @@ function createWebBootstrap(options) {
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: configuredPlayerInternalUrl, playerInternalBaseUrl: configuredPlayerInternalUrl,
bridgeInternalBaseUrl: configuredBridgeInternalUrl,
playerSnapshotCache: playerSnapshotCache, playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue backgroundTaskQueue: backgroundTaskQueue
+13 -18
View File
@@ -36,22 +36,6 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, ''); 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) { function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url); return normalizeBaseUrl(player && player.internal_base_url);
} }
@@ -68,6 +52,7 @@ function createUploadSyncService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens; const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -545,6 +530,9 @@ function createUploadSyncService(options) {
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) { if (!relativePath || !sourcePath) {
@@ -566,11 +554,13 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`, pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer 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, { const response = await fetch(mediaUploadUrl, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders ...authHeaders
}, },
body: fileBuffer body: fileBuffer
@@ -601,6 +591,9 @@ function createUploadSyncService(options) {
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) { if (!relativePath) {
return false; return false;
@@ -610,11 +603,13 @@ function createUploadSyncService(options) {
method: 'DELETE', method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}` 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, { const response = await fetch(mediaDeleteUrl, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders ...authHeaders
} }
}); });
+20 -2
View File
@@ -1,6 +1,6 @@
// Route screen refresh commands to the physical player hosting each screen. // 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') { if (typeof forwardPlayerCommand !== 'function') {
throw new Error('createNotifyPlayerScreens requires a player command sender.'); 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') { if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') {
const screenState = await getScreenConnections(slug); const screenState = await getScreenConnections(slug);
const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : []; 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) { const playerBaseUrls = Array.from(new Set(connections.map(function (connection) {
if (String(connection && connection.playerDeviceId || '').trim()) {
return '';
}
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean))); }).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) { if (playerBaseUrls.length) {
const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function' const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); })) ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); }))
: playerBaseUrls; : 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'); return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh');
})); }));
} }
if (deviceResults.length || baseUrlResults.length) {
return deviceResults.concat(baseUrlResults);
}
} }
return forwardPlayerCommand(slug, commandOrPayload || 'refresh'); return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) { })).then(function (results) {
+17
View File
@@ -243,6 +243,23 @@ function createPlayerActionService(options) {
} }
async function forwardAnnouncementRefresh(slug) { 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 resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([ const targetBaseUrls = Array.from(new Set([
resolvedPlayerInternalBaseUrl, resolvedPlayerInternalBaseUrl,
-4
View File
@@ -901,10 +901,6 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) {
opacity: 0.55;
}
.onboarding-pairing-progress { .onboarding-pairing-progress {
display: flex; display: flex;
align-items: center; align-items: center;
+26 -13
View File
@@ -44,6 +44,27 @@
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value); lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
scannerDebugOutput.textContent = lines.slice(-8).join('\n'); 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]')) : []; var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
function normalizeCode(value) { function normalizeCode(value) {
@@ -321,28 +342,21 @@
if (firstEmptyInput) { firstEmptyInput.focus(); } if (firstEmptyInput) { firstEmptyInput.focus(); }
return; return;
} }
message.className = 'alert alert-info'; message.className = 'alert d-none';
message.textContent = 'Pairing player...'; message.textContent = '';
var pairingBody = new URLSearchParams(new FormData(form)).toString(); var pairingBody = new URLSearchParams(new FormData(form)).toString();
if (pairingCard) { pairingCard.classList.add('is-pairing'); } if (pairingCard) { pairingCard.classList.add('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.remove('d-none'); } if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) { Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; } if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
}); });
fetch(form.action, { submitPairing(pairingBody, 0).then(function (result) {
method: 'POST', var response = result.response;
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, var payload = result.payload;
body: pairingBody
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
if (!response.ok) { if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.'); throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
} }
if (payload && payload.queued) { 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; return;
} }
message.className = 'alert alert-success'; message.className = 'alert alert-success';
@@ -363,7 +377,6 @@
} }
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); } if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
if (manualButton) { manualButton.classList.remove('d-none'); } if (manualButton) { manualButton.classList.remove('d-none'); }
});
}).catch(function (error) { }).catch(function (error) {
if (pairingCard) { pairingCard.classList.remove('is-pairing'); } if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); } 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 { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view'); const { renderView } = require('../view');
function wait(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}
module.exports = function registerOnboardingRoutes(app, deps) { module.exports = function registerOnboardingRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, ''); 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 resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' }); 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', method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders) 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; let resolved = null;
try { resolved = JSON.parse(resolveBody); } catch (_error) {} try { resolved = JSON.parse(resolveBody); } catch (_error) {}
if (!resolveResponse.ok || !resolved || !resolved.deviceId) { if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
@@ -61,11 +75,19 @@ module.exports = function registerOnboardingRoutes(app, deps) {
for (const remoteRegistration of remoteRegistrations) { for (const remoteRegistration of remoteRegistrations) {
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, ''); targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' }); 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', method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders) 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; } try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) { if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
break; break;
@@ -86,12 +108,20 @@ module.exports = function registerOnboardingRoutes(app, deps) {
} }
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug }; const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload }); 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', method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders), headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload) 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); res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
} catch (error) { } catch (error) {
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' }); 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' 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'); const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) { 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 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'); 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() { function createSandbox() {
@@ -77,7 +83,8 @@ function createSandbox() {
templateRenderPlanCache: Object.create(null), templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '', renderCacheViewportKey: '',
slideTransitionTimer: null, slideTransitionTimer: null,
slideFadeDurationMs: 560, slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
slideExpiresAt: null, slideExpiresAt: null,
pausedRemainingMs: null, pausedRemainingMs: null,
timer: null, timer: null,
@@ -119,6 +126,9 @@ function createSandbox() {
test('renderSlideMarkup runs post-render setup immediately', () => { test('renderSlideMarkup runs post-render setup immediately', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox(); const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox); loadCommandsScript(sandbox);
sandbox.playRegionAnimations = function () {
calls.playRegionAnimations += 1;
};
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false); 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); 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 () => { test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
const calls = { const calls = {
showCurrent: 0, showCurrent: 0,
@@ -247,8 +366,10 @@ test('single-slide playlists re-render the active slide instead of refreshing af
sandbox.XMLHttpRequest = XhrStub; sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js'); 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 commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const playbackScript = fs.readFileSync(scriptPath, 'utf8'); const playbackScript = fs.readFileSync(scriptPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8'); const commandsScript = fs.readFileSync(commandsPath, 'utf8');
const prelude = ` const prelude = `
var pendingPlaylistUpdate = null; 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 templateLayoutCache = Object.create(null);
var templateRenderPlanCache = 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 () { sandbox.showCurrent = function () {
calls.showCurrent += 1; 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(success, true);
assert.equal(fetchCalls.length, 1); 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 { } finally {
global.fetch = originalFetch; global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true }); 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) { assert.deepEqual(fetchCalls.map(function (call) {
return call.url; return call.url;
}).sort(), [ }).sort(), [
'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one', 'http://player-one:8081/api/media/uploads%2Fsample.bin',
'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two' 'http://player-two:8081/api/media/uploads%2Fsample.bin'
].sort()); ].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 { } finally {
global.fetch = originalFetch; global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true }); fs.rmSync(uploadDir, { recursive: true, force: true });