1250 lines
43 KiB
HTML
1250 lines
43 KiB
HTML
<script>
|
|
function parseTemplateJson(templateValue, fallbackValue) {
|
|
if (typeof templateValue !== 'string' || /^\s*\{\{.*\}\}\s*$/.test(templateValue)) {
|
|
return fallbackValue;
|
|
}
|
|
try {
|
|
return JSON.parse(templateValue);
|
|
} catch (_error) {
|
|
return fallbackValue;
|
|
}
|
|
}
|
|
|
|
const slug = parseTemplateJson('{{SLUG_JSON}}', '');
|
|
const initialData = parseTemplateJson('{{INITIAL_DATA_JSON}}', {});
|
|
const app = document.getElementById('app');
|
|
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
|
|
let currentPlaylistSignature = '';
|
|
let currentPlaylistEtag = '';
|
|
let currentPlaylistFadeBetweenSlides = false;
|
|
let pendingPlaylistUpdate = null;
|
|
let activeSlidesCacheKey = '';
|
|
let activeSlidesCacheValue = [];
|
|
let slideMarkupCache = Object.create(null);
|
|
let templateLayoutCache = Object.create(null);
|
|
let templateRenderPlanCache = Object.create(null);
|
|
let renderCacheViewportKey = '';
|
|
let index = 0;
|
|
let timer = null;
|
|
let slideTransitionTimer = null;
|
|
let preloadContainer = null;
|
|
let preloadSignature = '';
|
|
let commandSocket = null;
|
|
let commandReconnectTimer = null;
|
|
let commandStateTimer = null;
|
|
let viewportRenderTimer = null;
|
|
let commandClientId = null;
|
|
let lastRenderedSlide = null;
|
|
let lastRenderedViewKey = '';
|
|
let isPaused = false;
|
|
let isBlackout = false;
|
|
let pausedRemainingMs = null;
|
|
let slideExpiresAt = null;
|
|
const slideFadeDurationMs = 560;
|
|
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
|
|
const commandClientStorageKey = 'pulse-signage-player-client-id:' + slug;
|
|
|
|
// Escape text before inserting it into HTML.
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Render the empty-state message into the player root.
|
|
function renderEmpty(message) {
|
|
app.innerHTML = '<div class="empty">' + escapeHtml(message) + '</div>';
|
|
}
|
|
|
|
// Playlist and preload helpers.
|
|
// Collect unique webpage URLs from the slide list.
|
|
function getWebpageUrls(sourceSlides) {
|
|
const urls = [];
|
|
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
|
|
const content = slide && slide.content ? slide.content : {};
|
|
const regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
|
|
regions.forEach(function (region) {
|
|
if (region.region_type !== 'webpage') {
|
|
return;
|
|
}
|
|
const regionContent = content[region.region_key] || {};
|
|
const url = String(regionContent.value || '').trim();
|
|
if (url && urls.indexOf(url) === -1) {
|
|
urls.push(url);
|
|
}
|
|
});
|
|
});
|
|
return urls;
|
|
}
|
|
|
|
// Filter the slides down to those that are active right now.
|
|
function getActiveSlidesFrom(sourceSlides) {
|
|
const now = new Date();
|
|
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
|
|
return isSlideActive(slide, now);
|
|
});
|
|
}
|
|
|
|
// Build a cache key for the active slide set.
|
|
function getActiveSlidesCacheKey() {
|
|
const now = new Date();
|
|
return [
|
|
currentPlaylistSignature || '',
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate(),
|
|
now.getHours(),
|
|
now.getMinutes(),
|
|
now.getSeconds()
|
|
].join('|');
|
|
}
|
|
|
|
// Return the cached active slide set for the current playlist and second.
|
|
function getCurrentActiveSlides() {
|
|
const cacheKey = getActiveSlidesCacheKey();
|
|
if (cacheKey !== activeSlidesCacheKey) {
|
|
activeSlidesCacheValue = getActiveSlidesFrom(slides);
|
|
activeSlidesCacheKey = cacheKey;
|
|
}
|
|
return activeSlidesCacheValue;
|
|
}
|
|
|
|
// Clear the cached active slide set.
|
|
function clearActiveSlidesCache() {
|
|
activeSlidesCacheKey = '';
|
|
activeSlidesCacheValue = [];
|
|
}
|
|
|
|
// Reset render caches when the viewport changes.
|
|
function syncRenderCacheViewport() {
|
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
|
if (renderCacheViewportKey === viewportKey) {
|
|
return;
|
|
}
|
|
|
|
renderCacheViewportKey = viewportKey;
|
|
slideMarkupCache = Object.create(null);
|
|
templateLayoutCache = Object.create(null);
|
|
templateRenderPlanCache = Object.create(null);
|
|
}
|
|
|
|
// Build a signature for the currently rendered view.
|
|
function getCurrentRenderKey(activeSlides) {
|
|
const viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
|
const availableSlides = Array.isArray(activeSlides) ? activeSlides : [];
|
|
if (!availableSlides.length) {
|
|
return [currentPlaylistSignature || '', viewportKey, 'empty', slides.length ? 'scheduled' : 'assigned'].join('|');
|
|
}
|
|
let normalizedIndex = Number(index || 0);
|
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
|
normalizedIndex = 0;
|
|
}
|
|
const slide = availableSlides[normalizedIndex];
|
|
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
|
|
}
|
|
|
|
// Pick the current slide and the next slide for webpage preloading.
|
|
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
|
|
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
|
if (!availableSlides.length) {
|
|
return [];
|
|
}
|
|
|
|
let normalizedIndex = Number(targetIndex || 0);
|
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
|
normalizedIndex = 0;
|
|
}
|
|
|
|
const preloadSlides = [];
|
|
const currentSlide = availableSlides[normalizedIndex];
|
|
const nextSlide = availableSlides[normalizedIndex + 1];
|
|
|
|
if (currentSlide) {
|
|
preloadSlides.push(currentSlide);
|
|
}
|
|
if (nextSlide && nextSlide !== currentSlide) {
|
|
preloadSlides.push(nextSlide);
|
|
}
|
|
|
|
return preloadSlides;
|
|
}
|
|
|
|
// Mount hidden iframe preloads for the chosen webpage URLs.
|
|
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
|
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
|
const signature = urls.join('\\n');
|
|
if (signature === preloadSignature) {
|
|
return;
|
|
}
|
|
if (!urls.length) {
|
|
preloadSignature = '';
|
|
if (preloadContainer) {
|
|
preloadContainer.innerHTML = '';
|
|
}
|
|
return;
|
|
}
|
|
if (!preloadContainer) {
|
|
preloadContainer = document.createElement('div');
|
|
preloadContainer.className = 'webpage-preloads';
|
|
preloadContainer.setAttribute('aria-hidden', 'true');
|
|
document.body.appendChild(preloadContainer);
|
|
}
|
|
preloadContainer.innerHTML = urls.map(function (url) {
|
|
return '<iframe class="webpage-preload-frame" src="' + escapeHtml(url) + '" title="Webpage preload" tabindex="-1" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
|
}).join('');
|
|
preloadSignature = signature;
|
|
}
|
|
|
|
// Return a stable client id for this browser session.
|
|
function getCommandClientId() {
|
|
if (commandClientId) {
|
|
return commandClientId;
|
|
}
|
|
try {
|
|
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
|
|
if (storedClientId) {
|
|
commandClientId = storedClientId;
|
|
return commandClientId;
|
|
}
|
|
} catch (_error) {
|
|
// fall through to ephemeral ID generation
|
|
}
|
|
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
|
|
try {
|
|
window.localStorage.setItem(commandClientStorageKey, commandClientId);
|
|
} catch (_error2) {
|
|
// ignore storage errors
|
|
}
|
|
return commandClientId;
|
|
}
|
|
|
|
// Announce the player to the command websocket.
|
|
function sendCommandHello(socket) {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
socket.send(JSON.stringify({
|
|
type: 'hello',
|
|
clientId: getCommandClientId(),
|
|
userAgent: window.navigator.userAgent || '',
|
|
page: window.location.href,
|
|
viewport: getCurrentViewport(),
|
|
paused: isPaused,
|
|
blackout: isBlackout,
|
|
currentSlide: null
|
|
}));
|
|
}
|
|
|
|
// Capture the current viewport dimensions.
|
|
function getCurrentViewport() {
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
};
|
|
}
|
|
|
|
// Command websocket and player-state helpers.
|
|
// Send the current playback state to the command websocket.
|
|
function sendCommandState(currentSlide) {
|
|
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
commandSocket.send(JSON.stringify({
|
|
type: 'state',
|
|
clientId: getCommandClientId(),
|
|
userAgent: window.navigator.userAgent || '',
|
|
page: window.location.href,
|
|
viewport: getCurrentViewport(),
|
|
paused: isPaused,
|
|
blackout: isBlackout,
|
|
currentSlide: currentSlide ? {
|
|
id: currentSlide.id || null,
|
|
title: currentSlide.title || '',
|
|
kind: currentSlide.kind || '',
|
|
playlistSignature: currentPlaylistSignature || ''
|
|
} : null
|
|
}));
|
|
}
|
|
|
|
// Debounce command-state updates during rapid changes.
|
|
function scheduleCommandStateUpdate() {
|
|
if (commandStateTimer) {
|
|
window.clearTimeout(commandStateTimer);
|
|
}
|
|
commandStateTimer = window.setTimeout(function () {
|
|
commandStateTimer = null;
|
|
sendCommandState(lastRenderedSlide);
|
|
}, 300);
|
|
}
|
|
|
|
// Debounce rerenders after viewport changes.
|
|
function scheduleViewportRenderUpdate() {
|
|
if (viewportRenderTimer) {
|
|
window.clearTimeout(viewportRenderTimer);
|
|
}
|
|
viewportRenderTimer = window.setTimeout(function () {
|
|
viewportRenderTimer = null;
|
|
if (slides.length) {
|
|
var activeSlides = getCurrentActiveSlides();
|
|
if (getCurrentRenderKey(activeSlides) === lastRenderedViewKey) {
|
|
return;
|
|
}
|
|
showCurrent();
|
|
}
|
|
}, 150);
|
|
}
|
|
|
|
// Cancel the current slide-advance timer.
|
|
function clearSlideTimer() {
|
|
if (timer) {
|
|
window.clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
}
|
|
|
|
// Schedule the next slide transition.
|
|
function scheduleSlideAdvance(delayMs) {
|
|
clearSlideTimer();
|
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
|
slideExpiresAt = Date.now() + holdDelayMs;
|
|
timer = window.setTimeout(function () {
|
|
timer = null;
|
|
slideExpiresAt = null;
|
|
pausedRemainingMs = null;
|
|
const activeSlides = getCurrentActiveSlides();
|
|
if (activeSlides.length < 2) {
|
|
refresh();
|
|
return;
|
|
}
|
|
if (index >= activeSlides.length) {
|
|
index = 0;
|
|
}
|
|
index = (index + 1) % activeSlides.length;
|
|
showCurrent();
|
|
}, holdDelayMs);
|
|
}
|
|
|
|
// Cancel any pending fade-transition cleanup.
|
|
function clearSlideTransitionTimer() {
|
|
if (slideTransitionTimer) {
|
|
window.clearTimeout(slideTransitionTimer);
|
|
slideTransitionTimer = null;
|
|
}
|
|
}
|
|
|
|
// Swap slide markup with optional fade animation.
|
|
function renderSlideMarkup(markup, shouldFade) {
|
|
clearSlideTransitionTimer();
|
|
if (!shouldFade) {
|
|
app.innerHTML = markup;
|
|
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';
|
|
nextShell.style.opacity = '0';
|
|
nextShell.innerHTML = markup;
|
|
|
|
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
|
app.innerHTML = '';
|
|
nextShell.style.opacity = '1';
|
|
app.appendChild(nextShell);
|
|
return nextShell;
|
|
}
|
|
|
|
if (!previousShell.classList.contains('slide-shell')) {
|
|
previousShell.classList.add('slide-shell');
|
|
}
|
|
previousShell.style.opacity = '1';
|
|
|
|
app.appendChild(nextShell);
|
|
void nextShell.offsetHeight;
|
|
window.requestAnimationFrame(function () {
|
|
nextShell.style.opacity = '1';
|
|
previousShell.style.opacity = '0';
|
|
});
|
|
|
|
slideTransitionTimer = window.setTimeout(function () {
|
|
if (previousShell && previousShell.parentNode) {
|
|
previousShell.parentNode.removeChild(previousShell);
|
|
}
|
|
if (nextShell) {
|
|
nextShell.style.opacity = '1';
|
|
}
|
|
slideTransitionTimer = null;
|
|
}, slideFadeDurationMs);
|
|
|
|
return nextShell;
|
|
}
|
|
|
|
// Mirror blackout state onto the document body.
|
|
function syncBlackoutState() {
|
|
document.body.classList.toggle('screen-blackout', isBlackout);
|
|
}
|
|
|
|
// Apply pause state and preserve remaining slide time.
|
|
function setPaused(nextPaused) {
|
|
var normalized = Boolean(nextPaused);
|
|
if (isPaused === normalized) {
|
|
return;
|
|
}
|
|
if (normalized) {
|
|
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
|
|
isPaused = true;
|
|
clearSlideTimer();
|
|
sendCommandState(lastRenderedSlide);
|
|
return;
|
|
}
|
|
|
|
isPaused = false;
|
|
sendCommandState(lastRenderedSlide);
|
|
if (!slides.length || !lastRenderedSlide) {
|
|
return;
|
|
}
|
|
if (pausedRemainingMs !== null) {
|
|
scheduleSlideAdvance(pausedRemainingMs);
|
|
pausedRemainingMs = null;
|
|
}
|
|
}
|
|
|
|
// Apply blackout state and notify the server.
|
|
function setBlackout(nextBlackout) {
|
|
var normalized = Boolean(nextBlackout);
|
|
if (isBlackout === normalized) {
|
|
return;
|
|
}
|
|
isBlackout = normalized;
|
|
syncBlackoutState();
|
|
sendCommandState(lastRenderedSlide);
|
|
}
|
|
|
|
// Coerce command payload values into booleans or null.
|
|
function normalizeBoolean(value) {
|
|
if (value === true || value === false) {
|
|
return value;
|
|
}
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
var normalized = String(value).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].indexOf(normalized) !== -1) {
|
|
return true;
|
|
}
|
|
if (['0', 'false', 'no', 'off', ''].indexOf(normalized) !== -1) {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Move to the previous or next active slide.
|
|
function navigateSlides(offset) {
|
|
const manualSlides = getCurrentActiveSlides();
|
|
if (!manualSlides.length) {
|
|
return;
|
|
}
|
|
let currentIndex = manualSlides.findIndex(function (slide) {
|
|
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
|
|
});
|
|
if (currentIndex < 0) {
|
|
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
|
|
}
|
|
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
|
|
clearSlideTimer();
|
|
applyPendingPlaylistUpdate();
|
|
renderSlideAtIndex(manualSlides, nextIndex);
|
|
}
|
|
|
|
// Route incoming websocket command messages.
|
|
function handleCommandMessage(rawMessage) {
|
|
var payload;
|
|
try {
|
|
payload = JSON.parse(String(rawMessage || ''));
|
|
} catch (_error) {
|
|
return;
|
|
}
|
|
|
|
if (!payload || payload.type !== 'command') {
|
|
return;
|
|
}
|
|
|
|
switch (payload.command) {
|
|
case 'refresh':
|
|
refresh();
|
|
return;
|
|
case 'redirect':
|
|
if (payload.url) {
|
|
window.location.replace(String(payload.url));
|
|
}
|
|
return;
|
|
case 'pause':
|
|
setPaused(!isPaused);
|
|
return;
|
|
case 'blackout':
|
|
var desiredBlackout = normalizeBoolean(payload.blackout);
|
|
if (desiredBlackout !== null) {
|
|
setBlackout(desiredBlackout);
|
|
} else {
|
|
setBlackout(!isBlackout);
|
|
}
|
|
return;
|
|
case 'previous':
|
|
case 'left':
|
|
navigateSlides(-1);
|
|
return;
|
|
case 'next':
|
|
case 'right':
|
|
navigateSlides(1);
|
|
return;
|
|
case 'reload':
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Retry the command websocket after a disconnect.
|
|
function scheduleCommandReconnect() {
|
|
if (commandReconnectTimer) {
|
|
return;
|
|
}
|
|
commandReconnectTimer = window.setTimeout(function () {
|
|
commandReconnectTimer = null;
|
|
connectCommandSocket();
|
|
}, 5000);
|
|
}
|
|
|
|
// Open and wire the command websocket connection.
|
|
function connectCommandSocket() {
|
|
if (!window.WebSocket) {
|
|
return;
|
|
}
|
|
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var socket = new WebSocket(protocol + '//' + window.location.host + commandSocketPath);
|
|
commandSocket = socket;
|
|
|
|
socket.onopen = function () {
|
|
sendCommandHello(socket);
|
|
};
|
|
|
|
socket.onmessage = function (event) {
|
|
handleCommandMessage(event.data);
|
|
};
|
|
|
|
socket.onclose = function () {
|
|
commandSocket = null;
|
|
scheduleCommandReconnect();
|
|
};
|
|
|
|
socket.onerror = function () {
|
|
try {
|
|
socket.close();
|
|
} catch (_error) {
|
|
// ignore socket close errors
|
|
}
|
|
};
|
|
}
|
|
|
|
// Log player events with an optional severity level.
|
|
function logDebug(message, details, level) {
|
|
var logger = level === 'error' ? console.error : console.info;
|
|
if (details) {
|
|
logger(message, details);
|
|
} else {
|
|
logger(message);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Clamp font size to the supported range.
|
|
function sanitizeFontSize(value) {
|
|
return Math.max(8, Number(value || 0) || 24);
|
|
}
|
|
|
|
// Validate a text color and fall back when needed.
|
|
function sanitizeTextColor(value, fallback) {
|
|
var raw = String(value || '').trim();
|
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
return raw;
|
|
}
|
|
return fallback || '#000000';
|
|
}
|
|
|
|
// Read the template's canvas dimensions with safe defaults.
|
|
function getTemplateCanvasSize(template) {
|
|
return {
|
|
width: Math.max(1, Number(template.canvas_size_width || 1920)),
|
|
height: Math.max(1, Number(template.canvas_size_height || 1080))
|
|
};
|
|
}
|
|
|
|
// Read the server-supplied playlist revision, or fall back to the ETag.
|
|
function getPlaylistRevision(data) {
|
|
if (data && data.revision) {
|
|
return String(data.revision);
|
|
}
|
|
if (data && data.playlist && data.playlist.revision) {
|
|
return String(data.playlist.revision);
|
|
}
|
|
if (currentPlaylistEtag) {
|
|
return String(currentPlaylistEtag).replace(/^"|"$/g, '');
|
|
}
|
|
return String(Date.now());
|
|
}
|
|
|
|
// Scale a canvas to fit within the viewport.
|
|
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
|
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
|
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
|
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)
|
|
};
|
|
}
|
|
|
|
// Remove unsafe markup while preserving simple formatting tags.
|
|
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[^>]*)?>$/i);
|
|
if (!match) {
|
|
return '';
|
|
}
|
|
var closing = Boolean(match[1]);
|
|
var name = String(match[2] || '').toLowerCase();
|
|
var allowed = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
|
if (allowed.indexOf(name) === -1) {
|
|
return '';
|
|
}
|
|
if (name === 'br') {
|
|
return '<br>';
|
|
}
|
|
return closing ? '</' + name + '>' : '<' + name + '>';
|
|
});
|
|
}
|
|
|
|
// 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="editorjs-table">' + tableRows + '</table>';
|
|
}
|
|
|
|
// 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 sanitizeRichText(raw);
|
|
}
|
|
|
|
// Wrap HTML region content in a sandboxed iframe.
|
|
function renderHtmlRegionContent(value) {
|
|
var html = String(value || '').trim();
|
|
if (!html) {
|
|
return '<div class="template-region-placeholder">HTML</div>';
|
|
}
|
|
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
|
|
}
|
|
|
|
// 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]);
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
// Parse the stored schedule-day list into numbers.
|
|
function parseScheduleDays(value) {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); });
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? parsed.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); }) : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Convert a HH:MM time string to minutes since midnight.
|
|
function parseTimeToMinutes(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const match = raw.match(/^(\d{2}):(\d{2})/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
}
|
|
|
|
// Determine whether a slide should be shown at the current time.
|
|
function isSlideActive(slide, now) {
|
|
const mode = String(slide.schedule_mode || 'always');
|
|
if (mode === 'always') {
|
|
return true;
|
|
}
|
|
if (mode === 'dates') {
|
|
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
|
|
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
|
|
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
return false;
|
|
}
|
|
return now >= start && now <= end;
|
|
}
|
|
if (mode === 'times') {
|
|
const days = parseScheduleDays(slide.schedule_days_json);
|
|
if (!days.length) {
|
|
return false;
|
|
}
|
|
const day = now.getDay();
|
|
if (days.indexOf(day) === -1) {
|
|
return false;
|
|
}
|
|
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
|
|
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
|
|
if (startMinutes === null || endMinutes === null) {
|
|
return false;
|
|
}
|
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
if (startMinutes <= endMinutes) {
|
|
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
|
}
|
|
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Render the slide at the requested index within the active set.
|
|
function renderSlideAtIndex(sourceSlides, targetIndex) {
|
|
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.');
|
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
|
return false;
|
|
}
|
|
|
|
let normalizedIndex = Number(targetIndex || 0);
|
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
|
normalizedIndex = 0;
|
|
}
|
|
|
|
const slide = availableSlides[normalizedIndex];
|
|
if (!slide) {
|
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
|
return false;
|
|
}
|
|
|
|
index = normalizedIndex;
|
|
var markup = buildSlideMarkup(slide);
|
|
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
|
sendCommandState(slide);
|
|
if (!isPaused) {
|
|
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000);
|
|
}
|
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
|
return true;
|
|
}
|
|
|
|
// Promote a deferred playlist update at the next safe point.
|
|
function applyPendingPlaylistUpdate() {
|
|
if (!pendingPlaylistUpdate) {
|
|
return false;
|
|
}
|
|
slides = pendingPlaylistUpdate.slides;
|
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
|
pendingPlaylistUpdate = null;
|
|
clearActiveSlidesCache();
|
|
slideMarkupCache = Object.create(null);
|
|
templateLayoutCache = Object.create(null);
|
|
templateRenderPlanCache = Object.create(null);
|
|
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
|
index = 0;
|
|
logDebug('Applied updated playlist on slide transition.');
|
|
return true;
|
|
}
|
|
|
|
// Build the cache key for a template layout.
|
|
function getTemplateLayoutCacheKey(template) {
|
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
|
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey].join('|');
|
|
}
|
|
|
|
// Build or reuse layout metadata for a template.
|
|
function getTemplateLayout(template) {
|
|
if (!template || !template.id) {
|
|
return null;
|
|
}
|
|
|
|
syncRenderCacheViewport();
|
|
|
|
var cacheKey = getTemplateLayoutCacheKey(template);
|
|
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
|
|
return templateLayoutCache[cacheKey];
|
|
}
|
|
|
|
var templateCanvas = getTemplateCanvasSize(template);
|
|
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
|
|
var canvasScale = canvasSize.width / templateCanvas.width;
|
|
var regions = (template.regions || []).map(function (region) {
|
|
var left = (Number(region.x) / templateCanvas.width) * 100;
|
|
var top = (Number(region.y) / templateCanvas.height) * 100;
|
|
var width = (Number(region.width) / templateCanvas.width) * 100;
|
|
var height = (Number(region.height) / templateCanvas.height) * 100;
|
|
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
|
|
|
|
return {
|
|
regionKey: region.region_key,
|
|
regionType: region.region_type,
|
|
label: region.label,
|
|
baseStyle: baseStyle,
|
|
fontFamily: region.font_family || null,
|
|
fontSize: region.font_size || null,
|
|
fontColor: region.font_color || null,
|
|
canvasScale: canvasScale
|
|
};
|
|
});
|
|
|
|
var layout = {
|
|
canvasWidth: canvasSize.width,
|
|
canvasHeight: canvasSize.height,
|
|
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
|
regions: regions
|
|
};
|
|
|
|
templateLayoutCache[cacheKey] = layout;
|
|
return layout;
|
|
}
|
|
|
|
// Build the cache key for a template render plan.
|
|
function getTemplateRenderPlanCacheKey(template) {
|
|
return getTemplateLayoutCacheKey(template);
|
|
}
|
|
|
|
// Build or reuse the render plan for a template.
|
|
function getTemplateRenderPlan(template) {
|
|
if (!template || !template.id) {
|
|
return null;
|
|
}
|
|
|
|
syncRenderCacheViewport();
|
|
|
|
var cacheKey = getTemplateRenderPlanCacheKey(template);
|
|
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
|
|
return templateRenderPlanCache[cacheKey];
|
|
}
|
|
|
|
var layout = getTemplateLayout(template);
|
|
var plan = {
|
|
layout: layout,
|
|
renderRegion: function (region, regionContent) {
|
|
if (region.regionType === 'image') {
|
|
var src = regionContent.value || '';
|
|
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
|
|
}
|
|
if (region.regionType === 'webpage') {
|
|
var url = String(regionContent.value || '').trim();
|
|
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>';
|
|
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>';
|
|
}
|
|
if (region.regionType === 'html') {
|
|
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
|
|
}
|
|
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
|
var fontSize = sanitizeFontSize(regionContent.font_size);
|
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
|
var scaledFontSize = Math.max(1, Math.round(fontSize * region.canvasScale));
|
|
var style = region.baseStyle + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + scaledFontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
|
return '<div class="template-region text" style="' + style + '">' + renderEditorJsContent(regionContent.value || '') + '</div>';
|
|
}
|
|
};
|
|
|
|
templateRenderPlanCache[cacheKey] = plan;
|
|
return plan;
|
|
}
|
|
|
|
// Render a template-based slide using the cached layout.
|
|
function renderTemplateSlideMarkup(slide) {
|
|
const template = slide.template;
|
|
const content = slide.content || {};
|
|
const plan = getTemplateRenderPlan(template);
|
|
const layout = plan ? plan.layout : null;
|
|
const regions = layout ? layout.regions.map(function (region) {
|
|
const regionContent = content[region.regionKey] || {};
|
|
return plan.renderRegion(region, regionContent);
|
|
}).join('') : '';
|
|
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage">' + (layout ? layout.background : '') + regions + '</div>');
|
|
}
|
|
|
|
// Media rendering helpers.
|
|
// Build the direct media element for a slide.
|
|
function renderMediaSlideContent(slide) {
|
|
if (slide.kind === 'image') {
|
|
return '<img src="' + escapeHtml(slide.media_url) + '" alt="slide" />';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// Render a slide that contains direct media content.
|
|
function renderMediaSlideMarkup(slide) {
|
|
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
|
const media = renderMediaSlideContent(slide);
|
|
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
|
|
}
|
|
|
|
// Render the shared slide shell around slide-specific inner content.
|
|
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
|
|
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
|
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
|
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';"><div class="overlay"><div>' + escapeHtml(slide.title) + '</div></div>' + innerHtml + body + '</div></div>';
|
|
}
|
|
|
|
// Build the cache key for rendered slide markup.
|
|
function getSlideMarkupCacheKey(slide) {
|
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
|
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
|
|
}
|
|
|
|
// Look up a previously rendered slide in the cache.
|
|
function getCachedSlideMarkup(slide) {
|
|
var cacheKey = getSlideMarkupCacheKey(slide);
|
|
return Object.prototype.hasOwnProperty.call(slideMarkupCache, cacheKey) ? slideMarkupCache[cacheKey] : null;
|
|
}
|
|
|
|
// Store rendered slide markup in the cache.
|
|
function setCachedSlideMarkup(slide, markup) {
|
|
syncRenderCacheViewport();
|
|
slideMarkupCache[getSlideMarkupCacheKey(slide)] = markup;
|
|
}
|
|
|
|
// Slide rendering and markup cache helpers.
|
|
// Choose the right slide renderer and cache the result.
|
|
function buildSlideMarkup(slide) {
|
|
lastRenderedSlide = slide || null;
|
|
syncBlackoutState();
|
|
var cachedMarkup = getCachedSlideMarkup(slide);
|
|
if (cachedMarkup) {
|
|
return cachedMarkup;
|
|
}
|
|
|
|
var markup = '';
|
|
if (slide.template_id && slide.template) {
|
|
markup = renderTemplateSlideMarkup(slide);
|
|
setCachedSlideMarkup(slide, markup);
|
|
return markup;
|
|
}
|
|
|
|
markup = renderMediaSlideMarkup(slide);
|
|
setCachedSlideMarkup(slide, markup);
|
|
return markup;
|
|
}
|
|
|
|
// Render the current active slide or the empty state.
|
|
function showCurrent() {
|
|
clearSlideTimer();
|
|
applyPendingPlaylistUpdate();
|
|
const activeSlides = getCurrentActiveSlides();
|
|
syncWebpagePreloads(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;
|
|
}
|
|
renderSlideAtIndex(activeSlides, index);
|
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
|
}
|
|
|
|
// Fetch the latest playlist and queue any updates.
|
|
function refresh() {
|
|
var request = new XMLHttpRequest();
|
|
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
|
request.open('GET', url, true);
|
|
if (currentPlaylistEtag) {
|
|
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
|
}
|
|
request.onreadystatechange = function () {
|
|
if (request.readyState !== 4) {
|
|
return;
|
|
}
|
|
if (request.status === 304) {
|
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
|
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
|
}
|
|
return;
|
|
}
|
|
if (request.status < 200 || request.status >= 300) {
|
|
logDebug(
|
|
'Screen not found or playlist unavailable.',
|
|
['URL: ' + url, 'Status: ' + request.status + ' ' + request.statusText, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
|
'error'
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
|
const data = JSON.parse(request.responseText || '{}');
|
|
const nextSignature = getPlaylistRevision(data);
|
|
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
|
const currentActiveSlides = getCurrentActiveSlides();
|
|
if (responseEtag) {
|
|
currentPlaylistEtag = responseEtag;
|
|
}
|
|
if (!currentPlaylistSignature) {
|
|
slides = nextSlides;
|
|
currentPlaylistSignature = nextSignature;
|
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
|
index = 0;
|
|
showCurrent();
|
|
sendCommandState(lastRenderedSlide);
|
|
return;
|
|
}
|
|
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
|
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
|
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
|
}
|
|
return;
|
|
}
|
|
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
|
if (currentActiveSlides.length < 2) {
|
|
slides = nextSlides;
|
|
currentPlaylistSignature = nextSignature;
|
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
|
pendingPlaylistUpdate = null;
|
|
index = 0;
|
|
showCurrent();
|
|
sendCommandState(lastRenderedSlide);
|
|
return;
|
|
}
|
|
pendingPlaylistUpdate = {
|
|
slides: nextSlides,
|
|
signature: nextSignature,
|
|
fadeBetweenSlides: nextFadeBetweenSlides
|
|
};
|
|
logDebug('Playlist update detected; applying on next slide transition.');
|
|
} catch (_error) {
|
|
logDebug(
|
|
'Unable to load screen playlist.',
|
|
['URL: ' + url, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
|
'error'
|
|
);
|
|
}
|
|
};
|
|
request.onerror = function () {
|
|
logDebug(
|
|
'Unable to load screen playlist.',
|
|
['URL: ' + url, 'Network error during request.'].join(' | '),
|
|
'error'
|
|
);
|
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
|
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
|
}
|
|
};
|
|
request.send();
|
|
}
|
|
|
|
window.addEventListener('error', function (event) {
|
|
logDebug('Player script error.', String(event.message || event.error || 'unknown error'), 'error');
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', function (event) {
|
|
logDebug('Player promise error.', String(event.reason || 'unknown error'), 'error');
|
|
});
|
|
|
|
window.addEventListener('beforeunload', function () {
|
|
if (commandSocket) {
|
|
try {
|
|
commandSocket.close();
|
|
} catch (_error) {
|
|
// ignore shutdown errors
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', function () {
|
|
scheduleViewportRenderUpdate();
|
|
scheduleCommandStateUpdate();
|
|
});
|
|
|
|
window.addEventListener('orientationchange', function () {
|
|
scheduleViewportRenderUpdate();
|
|
scheduleCommandStateUpdate();
|
|
});
|
|
|
|
if (slides.length) {
|
|
currentPlaylistSignature = getPlaylistRevision(initialData || { slides: slides });
|
|
currentPlaylistFadeBetweenSlides = Boolean(initialData && initialData.playlist && initialData.playlist.fade_between_slides);
|
|
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
|
|
syncBlackoutState();
|
|
showCurrent();
|
|
refresh();
|
|
} else {
|
|
syncBlackoutState();
|
|
refresh();
|
|
}
|
|
connectCommandSocket();
|
|
</script> |