Release v2.10.6
This commit is contained in:
@@ -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,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,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": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ function normalizeStyleAttributeValue(value) {
|
|||||||
.replace(/&#39;/g, "'");
|
.replace(/&#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,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}`;
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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()())],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Vendored
+1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'); }
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user