Release v2.14.1
This commit is contained in:
1 parent
635e1a9942
commit
c3299abaac
174 files changed
+4483
-1687
No files matched your search
@@ -2,15 +2,7 @@
|
||||
var ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
var ANNOUNCEMENT_ICONS = Array.isArray(window.pulseAnnouncementIconKeys) ? window.pulseAnnouncementIconKeys : [];
|
||||
var DEFAULT_ANNOUNCEMENT_ICON = String(window.pulseAnnouncementDefaultIconKey || ANNOUNCEMENT_ICONS[0] || '').trim().toLowerCase();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
var escapeHtml = window.pulsePlayerRenderingShared.escapeHtml;
|
||||
|
||||
function normalizeAnnouncementType(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
|
||||
@@ -7,6 +7,7 @@ function getCurrentViewport() {
|
||||
}
|
||||
|
||||
var commandHeartbeatTimer = null;
|
||||
var commandReconnectDelayMs = 0;
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
@@ -61,137 +62,6 @@ function scheduleViewportRenderUpdate() {
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
module[lifecycleName](root);
|
||||
} catch (_error) {
|
||||
// Keep slide rendering resilient if a region lifecycle hook fails.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'destroyRegion');
|
||||
}
|
||||
|
||||
function initializeRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'initRegion');
|
||||
}
|
||||
|
||||
// Swap slide markup with optional fade animation.
|
||||
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
|
||||
clearSlideTransitionTimer();
|
||||
destroyRegionInstances(app);
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app);
|
||||
}
|
||||
|
||||
function schedulePostRenderSetup(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeRegionInstances(root);
|
||||
if (typeof initializeSlideMedia === 'function') {
|
||||
initializeSlideMedia(root, delayMs);
|
||||
}
|
||||
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(root, 'intro');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
schedulePostRenderSetup(app, 0);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||
var existingShells = topLevelChildren.filter(function (child) {
|
||||
return child && child.classList && child.classList.contains('slide-shell');
|
||||
});
|
||||
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||
|
||||
if (existingShells.length > 1) {
|
||||
existingShells.slice(0, -1).forEach(function (shell) {
|
||||
if (shell && shell.parentNode) {
|
||||
shell.parentNode.removeChild(shell);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell slide-shell-entering';
|
||||
nextShell.style.zIndex = '0';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
app.appendChild(nextShell);
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.classList.remove('is-visible');
|
||||
previousShell.classList.add('is-exiting');
|
||||
previousShell.style.zIndex = '1';
|
||||
previousShell.style.opacity = '1';
|
||||
|
||||
app.appendChild(nextShell);
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
});
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.zIndex = '1';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeLengthMs);
|
||||
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
// Mirror blackout state onto the document body.
|
||||
function syncBlackoutState() {
|
||||
document.body.classList.toggle('screen-blackout', isBlackout);
|
||||
@@ -207,11 +77,13 @@ function setPaused(nextPaused) {
|
||||
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
|
||||
isPaused = true;
|
||||
clearSlideTimer();
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
|
||||
isPaused = false;
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
if (!slides.length || !lastRenderedSlide) {
|
||||
return;
|
||||
@@ -230,6 +102,7 @@ function setBlackout(nextBlackout) {
|
||||
}
|
||||
isBlackout = normalized;
|
||||
syncBlackoutState();
|
||||
savePlaybackState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
}
|
||||
|
||||
@@ -357,10 +230,6 @@ function handleCommandMessage(rawMessage) {
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-id-conflict') {
|
||||
handleClientIdConflict();
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-name-updated') {
|
||||
if (payload.clientName) {
|
||||
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||
@@ -437,23 +306,31 @@ function handleCommandMessage(rawMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleClientIdConflict() {
|
||||
var replacementClientId = regenerateCommandClientId();
|
||||
var onboardingUrl = new URL('/', window.location.origin);
|
||||
window.location.replace(onboardingUrl.toString());
|
||||
}
|
||||
|
||||
// Retry the command websocket after a disconnect.
|
||||
function scheduleCommandReconnect() {
|
||||
if (commandReconnectTimer) {
|
||||
return;
|
||||
}
|
||||
var baseDelayMs = commandReconnectDelayMs
|
||||
? Math.min(commandReconnectDelayMs * 2, 30000)
|
||||
: 1000;
|
||||
commandReconnectDelayMs = baseDelayMs;
|
||||
var jitteredDelayMs = Math.round(baseDelayMs * (0.75 + Math.random() * 0.5));
|
||||
commandReconnectTimer = window.setTimeout(function () {
|
||||
commandReconnectTimer = null;
|
||||
connectCommandSocket();
|
||||
}, 5000);
|
||||
}, jitteredDelayMs);
|
||||
}
|
||||
|
||||
window.addEventListener('online', function () {
|
||||
if (commandReconnectTimer) {
|
||||
window.clearTimeout(commandReconnectTimer);
|
||||
commandReconnectTimer = null;
|
||||
}
|
||||
commandReconnectDelayMs = 0;
|
||||
connectCommandSocket();
|
||||
});
|
||||
|
||||
// Open and wire the command websocket connection.
|
||||
function connectCommandSocket() {
|
||||
if (!window.WebSocket) {
|
||||
@@ -466,6 +343,7 @@ function connectCommandSocket() {
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
commandReconnectDelayMs = 0;
|
||||
if (commandHeartbeatTimer) {
|
||||
window.clearInterval(commandHeartbeatTimer);
|
||||
}
|
||||
@@ -495,7 +373,9 @@ function connectCommandSocket() {
|
||||
}
|
||||
commandSocket = null;
|
||||
if (event && event.code === 4009) {
|
||||
handleClientIdConflict();
|
||||
regenerateCommandClientId();
|
||||
commandReconnectDelayMs = 0;
|
||||
scheduleCommandReconnect();
|
||||
return;
|
||||
}
|
||||
scheduleCommandReconnect();
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// Slide media startup and teardown helpers.
|
||||
|
||||
var renderedVideoPlaybackCleanup = new WeakMap();
|
||||
|
||||
function cancelRenderedVideoPlayback(video) {
|
||||
var cleanup = renderedVideoPlaybackCleanup.get(video);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
renderedVideoPlaybackCleanup.delete(video);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
@@ -12,14 +22,37 @@ function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRenderedVideoPlayback(video);
|
||||
var playbackScheduled = false;
|
||||
var playbackCancelled = false;
|
||||
var playbackTimer = null;
|
||||
|
||||
function removeReadinessListeners() {
|
||||
video.removeEventListener('canplay', schedulePlaybackStart);
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
}
|
||||
|
||||
// A cancelled callback can already be queued for dispatch, so it also checks this flag.
|
||||
renderedVideoPlaybackCleanup.set(video, function () {
|
||||
playbackCancelled = true;
|
||||
if (playbackTimer !== null) {
|
||||
window.clearTimeout(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
removeReadinessListeners();
|
||||
});
|
||||
|
||||
// Script-controlled startup aligns playback with the incoming slide's fade timing.
|
||||
video.autoplay = false;
|
||||
video.loop = !(video.dataset && video.dataset.loop === '0');
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
playbackTimer = null;
|
||||
if (playbackCancelled || video.isConnected === false || root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
@@ -29,28 +62,33 @@ function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
}
|
||||
|
||||
function schedulePlaybackStart() {
|
||||
if (playbackScheduled) {
|
||||
// Metadata and canplay can both fire; schedule only one start for this video.
|
||||
if (playbackCancelled || playbackScheduled) {
|
||||
return;
|
||||
}
|
||||
playbackScheduled = true;
|
||||
removeReadinessListeners();
|
||||
if (startDelayMs > 0) {
|
||||
window.setTimeout(startPlayback, startDelayMs);
|
||||
playbackTimer = window.setTimeout(startPlayback, startDelayMs);
|
||||
return;
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
function onLoadedMetadata() {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}
|
||||
|
||||
// HAVE_CURRENT_DATA means a frame is available, not just the video's metadata.
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}, { once: true });
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +125,28 @@ function initializeSlideMedia(root, delayMs) {
|
||||
initializeRenderedVideoPlayback(root, delayMs);
|
||||
}
|
||||
|
||||
function destroySlideMedia(root) {
|
||||
function destroySlideMedia(root, options) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var preserveVideoFrame = Boolean(options && options.preserveVideoFrame);
|
||||
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
cancelRenderedVideoPlayback(video);
|
||||
if (typeof video.pause === 'function') {
|
||||
video.pause();
|
||||
}
|
||||
// Keep the paused frame through a crossfade; release decoder/network resources at removal.
|
||||
if (!preserveVideoFrame) {
|
||||
video.removeAttribute('src');
|
||||
if (typeof video.load === 'function') {
|
||||
video.load();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// RTMP players hold independent HLS resources beyond the lifetime of their DOM nodes.
|
||||
if (typeof destroyRtmpRegions === 'function') {
|
||||
destroyRtmpRegions(root);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
var playlistRefreshInFlight = false;
|
||||
|
||||
// Render the slide at the requested index within the active set.
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
@@ -8,14 +11,14 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
}
|
||||
|
||||
let normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
var slideCount = availableSlides.length;
|
||||
var currentIndex = normalizedIndex;
|
||||
var slide = null;
|
||||
var attempts = 0;
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
|
||||
while (attempts < slideCount) {
|
||||
slide = availableSlides[currentIndex];
|
||||
@@ -35,7 +38,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
}
|
||||
}
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -56,6 +59,13 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
|
||||
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
|
||||
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
|
||||
if (typeof syncSlideMediaPreloads === 'function') {
|
||||
syncSlideMediaPreloads(availableSlides, currentIndex);
|
||||
}
|
||||
if (typeof markPlayerPlaybackProgress === 'function') {
|
||||
markPlayerPlaybackProgress();
|
||||
}
|
||||
savePlaybackState();
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
||||
}
|
||||
@@ -67,20 +77,6 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate the configured time from slide entry to the next transition.
|
||||
function getSlideAdvanceDelay(slide) {
|
||||
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
|
||||
if (slide && slide.use_video_duration) {
|
||||
return currentPlaylistFadeBetweenSlides
|
||||
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
|
||||
: getSlideHoldDelay(durationMs);
|
||||
}
|
||||
if (currentPlaylistFadeBetweenSlides) {
|
||||
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
|
||||
}
|
||||
return getSlideHoldDelay(durationMs);
|
||||
}
|
||||
|
||||
// Promote a deferred playlist update at the next safe point.
|
||||
function applyPendingPlaylistUpdate() {
|
||||
if (!pendingPlaylistUpdate) {
|
||||
@@ -117,15 +113,12 @@ function showCurrent(options) {
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(activeSlides, index);
|
||||
}
|
||||
if (!activeSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(activeSlides, index, options);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
var lastPlaylistRefreshAt = 0;
|
||||
|
||||
function isSlideInList(slide, slideList) {
|
||||
if (!slide || !Array.isArray(slideList)) {
|
||||
return false;
|
||||
@@ -163,8 +156,16 @@ function handleRtmpPlaybackFailure(message, options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch the latest playlist and queue any updates.
|
||||
function refresh(applyImmediately) {
|
||||
// Fetch and queue data updates; rendering and pause/resume own the slide timer.
|
||||
function refresh(applyImmediately, bypassDebounce) {
|
||||
if (playlistRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
if (!bypassDebounce && Date.now() - lastPlaylistRefreshAt < 1000) {
|
||||
return;
|
||||
}
|
||||
playlistRefreshInFlight = true;
|
||||
lastPlaylistRefreshAt = Date.now();
|
||||
var request = new XMLHttpRequest();
|
||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||
request.open('GET', url, true);
|
||||
@@ -175,25 +176,26 @@ function refresh(applyImmediately) {
|
||||
if (typeof getCommandClientId === 'function') {
|
||||
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
|
||||
}
|
||||
if (currentPlaylistEtag) {
|
||||
if (currentPlaylistEtag
|
||||
&& initialData
|
||||
&& Array.isArray(initialData.apiSources)
|
||||
&& Array.isArray(initialData.weatherLocations)) {
|
||||
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
||||
}
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4) {
|
||||
return;
|
||||
}
|
||||
playlistRefreshInFlight = false;
|
||||
if (request.status === 304) {
|
||||
if (!initialData || !Array.isArray(initialData.apiSources) || !Array.isArray(initialData.weatherLocations)) {
|
||||
currentPlaylistEtag = '';
|
||||
refresh(applyImmediately);
|
||||
refresh(applyImmediately, true);
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist refresh completed with no changes.');
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
@@ -249,7 +251,6 @@ function refresh(applyImmediately) {
|
||||
});
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
const currentActiveSlides = getCurrentActiveSlides();
|
||||
if (responseEtag) {
|
||||
currentPlaylistEtag = responseEtag;
|
||||
}
|
||||
@@ -268,9 +269,6 @@ function refresh(applyImmediately) {
|
||||
showCurrent({ skipFade: true });
|
||||
return;
|
||||
}
|
||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
return;
|
||||
}
|
||||
syncWebpagePreloads(nextActiveSlides, index);
|
||||
@@ -287,16 +285,6 @@ function refresh(applyImmediately) {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
return;
|
||||
}
|
||||
@@ -306,11 +294,6 @@ function refresh(applyImmediately) {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
} catch (_error) {
|
||||
logDebug(
|
||||
@@ -323,6 +306,7 @@ function refresh(applyImmediately) {
|
||||
}
|
||||
};
|
||||
request.onerror = function () {
|
||||
playlistRefreshInFlight = false;
|
||||
logDebug(
|
||||
'Unable to load screen playlist.',
|
||||
['URL: ' + url, 'Network error during request.'].join(' | '),
|
||||
@@ -330,11 +314,9 @@ function refresh(applyImmediately) {
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
};
|
||||
request.ontimeout = function () {
|
||||
playlistRefreshInFlight = false;
|
||||
logDebug(
|
||||
'Playlist refresh timed out.',
|
||||
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
|
||||
@@ -342,9 +324,6 @@ function refresh(applyImmediately) {
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
|
||||
}
|
||||
};
|
||||
request.send();
|
||||
}
|
||||
@@ -158,6 +158,148 @@ function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function deferVideoSourcesForPreload(markup) {
|
||||
return String(markup || '').replace(/(<video\b[^>]*?)\s+src=("([^"]*)"|'([^']*)')/gi, function (_match, openingTag, quote, doubleQuoted, singleQuoted) {
|
||||
const source = doubleQuoted !== undefined ? doubleQuoted : singleQuoted;
|
||||
return openingTag + ' data-pulse-video-src=' + quote + source + quote;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSlideMediaPreloads() {
|
||||
mediaPreloadElements.forEach(function (element) {
|
||||
if (!element || element.tagName !== 'VIDEO' || element.parentNode) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.pause === 'function') {
|
||||
element.pause();
|
||||
}
|
||||
if (typeof element.removeAttribute === 'function') {
|
||||
element.removeAttribute('src');
|
||||
}
|
||||
if (typeof element.load === 'function') {
|
||||
element.load();
|
||||
}
|
||||
});
|
||||
mediaPreloadElements = [];
|
||||
mediaPreloadSignature = '';
|
||||
}
|
||||
|
||||
function promotePreloadedVideoElements(root, slideId) {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedSlideId = String(slideId || '');
|
||||
var promotedElements = [];
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.template-region.video video, .slide-media video'), function (activeVideo) {
|
||||
var activeSource = String(activeVideo && (activeVideo.getAttribute('data-pulse-video-src') || activeVideo.getAttribute('src') || activeVideo.src) || '').trim();
|
||||
if (!activeSource || !activeVideo.parentNode) {
|
||||
return;
|
||||
}
|
||||
var absoluteSource = new URL(activeSource, window.location.origin).toString();
|
||||
var preloadedVideo = mediaPreloadElements.find(function (element) {
|
||||
return element && element.tagName === 'VIDEO'
|
||||
&& String(element.__pulsePreloadSlideId || '') === normalizedSlideId
|
||||
&& String(element.src || '').trim() === absoluteSource;
|
||||
});
|
||||
if (!preloadedVideo) {
|
||||
if (activeVideo.getAttribute('data-pulse-video-src')) {
|
||||
activeVideo.removeAttribute('data-pulse-video-src');
|
||||
activeVideo.setAttribute('src', activeSource);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(activeVideo.attributes || [], function (attribute) {
|
||||
var attributeName = String(attribute.name || '').toLowerCase();
|
||||
if (attributeName !== 'src' && attributeName !== 'data-pulse-video-src') {
|
||||
preloadedVideo.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
});
|
||||
preloadedVideo.preload = activeVideo.preload || 'auto';
|
||||
preloadedVideo.muted = Boolean(activeVideo.muted);
|
||||
preloadedVideo.loop = Boolean(activeVideo.loop);
|
||||
preloadedVideo.playsInline = Boolean(activeVideo.playsInline);
|
||||
preloadedVideo.autoplay = Boolean(activeVideo.autoplay);
|
||||
preloadedVideo.removeAttribute('data-pulse-video-src');
|
||||
if (String(preloadedVideo.src || '').trim() !== absoluteSource) {
|
||||
preloadedVideo.src = absoluteSource;
|
||||
}
|
||||
activeVideo.parentNode.replaceChild(preloadedVideo, activeVideo);
|
||||
promotedElements.push(preloadedVideo);
|
||||
});
|
||||
|
||||
if (promotedElements.length) {
|
||||
mediaPreloadElements = mediaPreloadElements.filter(function (element) {
|
||||
return promotedElements.indexOf(element) === -1;
|
||||
});
|
||||
}
|
||||
|
||||
return promotedElements.length;
|
||||
}
|
||||
|
||||
function syncSlideMediaPreloads(sourceSlides, targetIndex) {
|
||||
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (availableSlides.length < 2) {
|
||||
clearSlideMediaPreloads();
|
||||
return;
|
||||
}
|
||||
var currentIndex = Math.min(availableSlides.length - 1, Math.max(0, Number(targetIndex || 0)));
|
||||
var nextSlide = availableSlides[(currentIndex + 1) % availableSlides.length];
|
||||
if (!nextSlide || typeof primeSlideMarkup !== 'function' || typeof document === 'undefined') {
|
||||
clearSlideMediaPreloads();
|
||||
return;
|
||||
}
|
||||
|
||||
var signature = [
|
||||
currentPlaylistSignature || '',
|
||||
nextSlide.id || '',
|
||||
nextSlide.modified_at || '',
|
||||
window.innerWidth + 'x' + window.innerHeight
|
||||
].join('|');
|
||||
if (signature === mediaPreloadSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSlideMediaPreloads();
|
||||
mediaPreloadSignature = signature;
|
||||
|
||||
var holder = document.createElement('div');
|
||||
holder.innerHTML = deferVideoSourcesForPreload(primeSlideMarkup(nextSlide) || '');
|
||||
var mediaUrls = [];
|
||||
Array.prototype.forEach.call(holder.querySelectorAll('img[src], video[data-pulse-video-src]'), function (element) {
|
||||
var isVideo = element.tagName === 'VIDEO';
|
||||
var src = String(element.getAttribute(isVideo ? 'data-pulse-video-src' : 'src') || '').trim();
|
||||
if (!src || mediaUrls.indexOf(src) !== -1) {
|
||||
return;
|
||||
}
|
||||
mediaUrls.push(src);
|
||||
var absoluteUrl = new URL(src, window.location.origin).toString();
|
||||
if (element.tagName === 'VIDEO') {
|
||||
var video = document.createElement('video');
|
||||
Array.prototype.forEach.call(element.attributes || [], function (attribute) {
|
||||
if (String(attribute.name || '').toLowerCase() !== 'src') {
|
||||
video.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
});
|
||||
video.preload = 'auto';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.__pulsePreloadSlideId = String(nextSlide.id || '');
|
||||
video.src = absoluteUrl;
|
||||
if (typeof video.load === 'function') {
|
||||
video.load();
|
||||
}
|
||||
mediaPreloadElements.push(video);
|
||||
return;
|
||||
}
|
||||
var image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.src = absoluteUrl;
|
||||
mediaPreloadElements.push(image);
|
||||
});
|
||||
}
|
||||
|
||||
// Mount hidden iframe preloads for the chosen webpage URLs.
|
||||
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
||||
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
||||
@@ -247,6 +389,7 @@ function savePlaylistSnapshot(data) {
|
||||
slides: Array.isArray(data && data.slides) ? data.slides : [],
|
||||
signature: String(data && data.signature || ''),
|
||||
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
|
||||
skipUnavailableRtmp: Boolean(data && data.skipUnavailableRtmp),
|
||||
etag: String(data && data.etag || ''),
|
||||
savedAt: new Date().toISOString()
|
||||
}));
|
||||
@@ -263,6 +406,7 @@ function applyPlaylistSnapshot(data) {
|
||||
slides = data.slides.map(normalizeSlide);
|
||||
currentPlaylistSignature = String(data.signature || '');
|
||||
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(data.skipUnavailableRtmp);
|
||||
currentPlaylistEtag = String(data.etag || '');
|
||||
pendingPlaylistUpdate = null;
|
||||
clearActiveSlidesCache();
|
||||
@@ -272,4 +416,62 @@ function applyPlaylistSnapshot(data) {
|
||||
renderCacheViewportKey = '';
|
||||
index = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPlaybackState() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(playbackStateStorageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
signature: String(parsed.signature || ''),
|
||||
slideId: parsed.slideId === null || parsed.slideId === undefined ? null : String(parsed.slideId),
|
||||
index: Number.isFinite(Number(parsed.index)) ? Number(parsed.index) : 0,
|
||||
paused: Boolean(parsed.paused),
|
||||
blackout: Boolean(parsed.blackout)
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePlaybackState() {
|
||||
try {
|
||||
window.localStorage.setItem(playbackStateStorageKey, JSON.stringify({
|
||||
signature: String(currentPlaylistSignature || ''),
|
||||
slideId: lastRenderedSlide && lastRenderedSlide.id !== undefined ? String(lastRenderedSlide.id) : null,
|
||||
index: Number(index || 0),
|
||||
paused: Boolean(isPaused),
|
||||
blackout: Boolean(isBlackout),
|
||||
savedAt: new Date().toISOString()
|
||||
}));
|
||||
} catch (_error) {
|
||||
// Ignore storage errors so playback remains unaffected.
|
||||
}
|
||||
}
|
||||
|
||||
function restorePlaybackState() {
|
||||
var state = loadPlaybackState();
|
||||
if (!state || state.signature !== String(currentPlaylistSignature || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var restoredIndex = state.index;
|
||||
var matchingSlideIndex = slides.findIndex(function (slide) {
|
||||
return slide && state.slideId !== null && String(slide.id) === state.slideId;
|
||||
});
|
||||
if (matchingSlideIndex >= 0) {
|
||||
restoredIndex = matchingSlideIndex;
|
||||
}
|
||||
if (slides.length) {
|
||||
index = Math.min(Math.max(0, Number(restoredIndex) || 0), slides.length - 1);
|
||||
}
|
||||
isPaused = state.paused;
|
||||
isBlackout = state.blackout;
|
||||
return true;
|
||||
}
|
||||
@@ -1,28 +1,21 @@
|
||||
// General sanitization and sizing helpers.
|
||||
// Strip unsupported characters from a font family string.
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeStyleAttributeValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&quot;/g, '"')
|
||||
.replace(/&#39;/g, "'");
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
var raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
var playerRenderingShared = window.pulsePlayerRenderingShared;
|
||||
var escapeHtml = playerRenderingShared.escapeHtml;
|
||||
var normalizeStyleAttributeValue = playerRenderingShared.normalizeStyleAttributeValue;
|
||||
var sanitizeFontFamily = playerRenderingShared.sanitizeFontFamily;
|
||||
var sanitizeFontSize = playerRenderingShared.sanitizeFontSize;
|
||||
var sanitizeTextColor = playerRenderingShared.sanitizeTextColor;
|
||||
var sanitizeRichTextAttributes = playerRenderingShared.sanitizeRichTextAttributes;
|
||||
var sanitizeRichText = playerRenderingShared.sanitizeRichText;
|
||||
var renderEditorJsBlock = playerRenderingShared.renderEditorJsBlock;
|
||||
var renderEditorJsListItem = playerRenderingShared.renderEditorJsListItem;
|
||||
var renderEditorJsTable = playerRenderingShared.renderEditorJsTable;
|
||||
var renderEditorJsContent = playerRenderingShared.renderEditorJsContent;
|
||||
var parseMaybeJson = playerRenderingShared.parseMaybeJson;
|
||||
var normalizeContentValue = playerRenderingShared.normalizeContentValue;
|
||||
var fitCanvasSize = playerRenderingShared.fitCanvasSize;
|
||||
var decorateRegionMarkup = playerRenderingShared.decorateRegionMarkup;
|
||||
|
||||
function getTemplateCanvasSize(template) {
|
||||
return {
|
||||
@@ -31,6 +24,17 @@ function getTemplateCanvasSize(template) {
|
||||
};
|
||||
}
|
||||
|
||||
function selectPlayerMediaSource(originalSource, variants, targetDimension) {
|
||||
var original = String(originalSource || '').trim();
|
||||
var target = Math.max(1, Number(targetDimension || 0) || 1);
|
||||
var candidates = (Array.isArray(variants) ? variants : []).filter(function (variant) {
|
||||
return variant && String(variant.url || '').trim() && Number(variant.width) >= target;
|
||||
}).sort(function (left, right) {
|
||||
return Number(left.width) - Number(right.width);
|
||||
});
|
||||
return candidates.length ? String(candidates[0].url) : original;
|
||||
}
|
||||
|
||||
function getPlaylistRevision(data) {
|
||||
if (data && data.revision) {
|
||||
return String(data.revision);
|
||||
@@ -44,36 +48,6 @@ function getPlaylistRevision(data) {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
var scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
var preset = String((config[stepName] && config[stepName].preset) || '').trim();
|
||||
return preset && preset !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
@@ -85,264 +59,9 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
// Remove unsafe markup while preserving richer rich-text formatting.
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
// Render a single Editor.js block to HTML.
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
var items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
|
||||
if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
}
|
||||
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render a list item and any nested sub-items.
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
// Render an Editor.js table block.
|
||||
function renderEditorJsTable(data) {
|
||||
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
var hasHeadings = Boolean(data.withHeadings);
|
||||
var tableRows = rows.map(function (row, rowIndex) {
|
||||
var cells = Array.isArray(row) ? row : [];
|
||||
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
// Render Editor.js JSON or plain content safely.
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
var raw = String(value || '');
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
// Parse string values that look like JSON.
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
var raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a slide region's stored content value.
|
||||
function normalizeContentValue(value) {
|
||||
var normalized;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
}
|
||||
if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
}
|
||||
if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
}
|
||||
if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
}
|
||||
if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Normalize a slide and its nested region content.
|
||||
function normalizeSlide(slide) {
|
||||
var normalized = {};
|
||||
var content;
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
var normalized = playerRenderingShared.normalizeSlide(slide);
|
||||
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]) {
|
||||
@@ -520,6 +239,8 @@ function getTemplateLayout(template) {
|
||||
baseStyle: baseStyle,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
canvasWidth: templateCanvas.width,
|
||||
canvasHeight: templateCanvas.height,
|
||||
fontFamily: region.font_family || null,
|
||||
fontSize: region.font_size || null,
|
||||
fontColor: region.font_color || null,
|
||||
@@ -530,7 +251,7 @@ function getTemplateLayout(template) {
|
||||
var layout = {
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(templateCanvas.width, templateCanvas.height))) + '" alt="" />' : '',
|
||||
backgroundColor: template.background_color || '#111111',
|
||||
backgroundGradient: template.background_gradient || '',
|
||||
regions: regions
|
||||
@@ -641,7 +362,8 @@ function renderTemplateSlideMarkup(slide) {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path, layout.backgroundGradient) : '';
|
||||
const backgroundPath = layout ? selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(layout.canvasWidth, layout.canvasHeight)) : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', backgroundPath, layout.backgroundGradient) : '';
|
||||
if (layout) {
|
||||
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
// Slide transition timing, cancellation, and outgoing animation coordination.
|
||||
|
||||
var slideOutroTimers = [];
|
||||
var slideTransitionRequestId = 0;
|
||||
var playerWatchdogTimer = null;
|
||||
var playerLastProgressAt = Date.now();
|
||||
var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries';
|
||||
|
||||
function markPlayerPlaybackProgress() {
|
||||
playerLastProgressAt = Date.now();
|
||||
try {
|
||||
window.sessionStorage.removeItem(playerWatchdogRecoveryKey);
|
||||
} catch (_error) {
|
||||
// Session storage may be unavailable in restricted kiosk profiles.
|
||||
}
|
||||
}
|
||||
|
||||
function startPlayerWatchdog() {
|
||||
if (playerWatchdogTimer || typeof window.setInterval !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
playerWatchdogTimer = window.setInterval(function () {
|
||||
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide) {
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedDurationMs = Math.max(10000, Number(getSlideAdvanceDelay(lastRenderedSlide) || 10000));
|
||||
if (Date.now() - playerLastProgressAt <= (expectedDurationMs * 2) + 15000) {
|
||||
return;
|
||||
}
|
||||
|
||||
var recoveryCount = 0;
|
||||
try {
|
||||
recoveryCount = Number(window.sessionStorage.getItem(playerWatchdogRecoveryKey) || 0);
|
||||
} catch (_error) {
|
||||
recoveryCount = 0;
|
||||
}
|
||||
if (recoveryCount >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(playerWatchdogRecoveryKey, String(recoveryCount + 1));
|
||||
} catch (_error) {
|
||||
// Continue with the reload if session storage is unavailable.
|
||||
}
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug('Player watchdog is reloading a stalled playback page.', 'No slide progress detected.', 'error');
|
||||
}
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Cancel the current slide-advance timer.
|
||||
function clearSlideTimer() {
|
||||
@@ -92,13 +142,148 @@ function getSlideHoldDelay(delayMs) {
|
||||
|
||||
// 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);
|
||||
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
|
||||
// Video-duration slides start immediately; other slides start midway through the fade.
|
||||
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);
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
// Cancel fade cleanup and invalidate callbacks already queued for an older transition.
|
||||
function clearSlideTransitionTimer() {
|
||||
slideTransitionRequestId += 1;
|
||||
if (slideTransitionTimer) {
|
||||
window.clearTimeout(slideTransitionTimer);
|
||||
slideTransitionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap slide markup with optional fade animation.
|
||||
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
|
||||
clearSlideTransitionTimer();
|
||||
var transitionRequestId = slideTransitionRequestId;
|
||||
if (typeof deferVideoSourcesForPreload === 'function') {
|
||||
markup = deferVideoSourcesForPreload(markup);
|
||||
}
|
||||
// Stop outgoing region activity before overlapping its shell with the incoming slide.
|
||||
destroyRegionInstances(app);
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app, { preserveVideoFrame: shouldFade });
|
||||
}
|
||||
|
||||
function schedulePostRenderSetup(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeRegionInstances(root);
|
||||
if (typeof initializeSlideMedia === 'function') {
|
||||
initializeSlideMedia(root, delayMs);
|
||||
}
|
||||
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(root, 'intro');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof promotePreloadedVideoElements === 'function') {
|
||||
promotePreloadedVideoElements(app, lastRenderedSlide && lastRenderedSlide.id);
|
||||
}
|
||||
schedulePostRenderSetup(app, 0);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||
var existingShells = topLevelChildren.filter(function (child) {
|
||||
return child && child.classList && child.classList.contains('slide-shell');
|
||||
});
|
||||
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||
|
||||
if (existingShells.length > 1) {
|
||||
// Rapid navigation can interrupt cleanup, leaving older crossfade shells behind.
|
||||
existingShells.slice(0, -1).forEach(function (shell) {
|
||||
if (shell && shell.parentNode) {
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(shell);
|
||||
}
|
||||
shell.parentNode.removeChild(shell);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell slide-shell-entering';
|
||||
nextShell.style.zIndex = '0';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
if (typeof promotePreloadedVideoElements === 'function') {
|
||||
promotePreloadedVideoElements(nextShell, lastRenderedSlide && lastRenderedSlide.id);
|
||||
}
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
if (previousShell && typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(app);
|
||||
}
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
app.appendChild(nextShell);
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.classList.remove('is-visible');
|
||||
previousShell.classList.add('is-exiting');
|
||||
previousShell.style.zIndex = '1';
|
||||
previousShell.style.opacity = '1';
|
||||
|
||||
app.appendChild(nextShell);
|
||||
// Commit the initial opacity before starting the crossfade on the next paint frame.
|
||||
window.requestAnimationFrame(function () {
|
||||
if (transitionRequestId !== slideTransitionRequestId) {
|
||||
return;
|
||||
}
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
|
||||
});
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (transitionRequestId !== slideTransitionRequestId) {
|
||||
return;
|
||||
}
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
if (typeof destroySlideMedia === 'function') {
|
||||
destroySlideMedia(previousShell);
|
||||
}
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.zIndex = '1';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeLengthMs);
|
||||
|
||||
return nextShell;
|
||||
}
|
||||
@@ -31,4 +31,39 @@
|
||||
get: get,
|
||||
list: list
|
||||
};
|
||||
}());
|
||||
}());
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
module[lifecycleName](root);
|
||||
} catch (_error) {
|
||||
// Keep slide rendering resilient if a region lifecycle hook fails.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'destroyRegion');
|
||||
}
|
||||
|
||||
function initializeRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'initRegion');
|
||||
}
|
||||
Reference in new issue
Block a user