502 lines
19 KiB
JavaScript
502 lines
19 KiB
JavaScript
// General sanitization and sizing helpers.
|
|
// Strip unsupported characters from a font family string.
|
|
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 {
|
|
width: Math.max(1, Number(template.canvas_size_width || 1920)),
|
|
height: Math.max(1, Number(template.canvas_size_height || 1080))
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
if (data && data.playlist && data.playlist.revision) {
|
|
return String(data.playlist.revision);
|
|
}
|
|
if (currentPlaylistEtag) {
|
|
return String(currentPlaylistEtag).replace(/^"|"$/g, '');
|
|
}
|
|
return String(Date.now());
|
|
}
|
|
|
|
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
|
if (!document || !document.documentElement) {
|
|
return;
|
|
}
|
|
|
|
var width = Math.max(1, Math.round(Number(canvasWidth) || 0) || 0);
|
|
var height = Math.max(1, Math.round(Number(canvasHeight) || 0) || 0);
|
|
document.documentElement.style.setProperty('--player-canvas-width', width + 'px');
|
|
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
|
}
|
|
|
|
// Normalize a slide and its nested region content.
|
|
function normalizeSlide(slide) {
|
|
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]) {
|
|
normalized.content[region.region_key].loop = false;
|
|
}
|
|
});
|
|
}
|
|
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]);
|
|
}
|
|
|
|
function normalizeScheduleRule(rule) {
|
|
const input = rule && typeof rule === 'object' ? rule : {};
|
|
const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
|
|
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
|
const startTime = String(input.start_time || input.startTime || '').trim();
|
|
const endTime = String(input.end_time || input.endTime || '').trim();
|
|
const days = parseScheduleDays(input.days);
|
|
|
|
if (startDatetime || endDatetime) {
|
|
if (!startDatetime || !endDatetime) {
|
|
return null;
|
|
}
|
|
}
|
|
if (startTime || endTime) {
|
|
if (!startTime || !endTime) {
|
|
return null;
|
|
}
|
|
}
|
|
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
start_datetime: startDatetime || null,
|
|
end_datetime: endDatetime || null,
|
|
start_time: startTime || null,
|
|
end_time: endTime || null,
|
|
days: days
|
|
};
|
|
}
|
|
|
|
function parseScheduleRules(value) {
|
|
let rawRules = [];
|
|
|
|
if (Array.isArray(value)) {
|
|
rawRules = value;
|
|
} else {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
rawRules = Array.isArray(parsed) ? parsed : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return rawRules.map(normalizeScheduleRule).filter(Boolean);
|
|
}
|
|
|
|
function matchesScheduleRule(rule, now) {
|
|
const normalized = normalizeScheduleRule(rule);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
if (normalized.start_datetime && normalized.end_datetime) {
|
|
const start = new Date(normalized.start_datetime);
|
|
const end = new Date(normalized.end_datetime);
|
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
return false;
|
|
}
|
|
if (now < start || now > end) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
|
|
return false;
|
|
}
|
|
|
|
if (normalized.start_time && normalized.end_time) {
|
|
const startMinutes = parseTimeToMinutes(normalized.start_time);
|
|
const endMinutes = parseTimeToMinutes(normalized.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;
|
|
}
|
|
|
|
// Determine whether a slide should be shown at the current time.
|
|
function isSlideActive(slide, now) {
|
|
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
|
|
if (rules.length && !rules.some(function (rule) {
|
|
return matchesScheduleRule(rule, now);
|
|
})) {
|
|
return false;
|
|
}
|
|
const groupRules = parseScheduleRules(slide.groupScheduleRules || slide.group_schedule_rules_json);
|
|
if (slide.groupKey && groupRules.length && !groupRules.some(function (rule) {
|
|
return matchesScheduleRule(rule, now);
|
|
})) {
|
|
return false;
|
|
}
|
|
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) + ';';
|
|
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
|
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
|
|
|
return {
|
|
regionKey: region.region_key,
|
|
regionType: region.region_type,
|
|
label: region.label,
|
|
animationJson: region.animation_json || null,
|
|
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,
|
|
canvasScale: canvasScale
|
|
};
|
|
});
|
|
|
|
var layout = {
|
|
canvasWidth: canvasSize.width,
|
|
canvasHeight: canvasSize.height,
|
|
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
|
|
};
|
|
|
|
templateLayoutCache[cacheKey] = layout;
|
|
return layout;
|
|
}
|
|
|
|
// Build a dark backdrop style for template and media canvases.
|
|
function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGradient) {
|
|
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
|
var style = 'background-color:' + escapeHtml(color) + ';';
|
|
var gradient = '';
|
|
try {
|
|
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
|
|
if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
|
|
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
|
|
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
|
if (stops.length >= 2) {
|
|
var angle = Number(gradientData.angle);
|
|
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
gradient = '';
|
|
}
|
|
|
|
if (backgroundImagePath) {
|
|
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '")' + (gradient ? ',' + gradient : '') + ';';
|
|
style += 'background-position:center,center;background-size:contain,cover;background-repeat:no-repeat,no-repeat;';
|
|
return style;
|
|
}
|
|
|
|
style += 'background-image:' + (gradient || 'none') + ';background-position:center;background-size:cover;background-repeat:no-repeat;';
|
|
return style;
|
|
}
|
|
|
|
// 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);
|
|
function getPlayerRegionModule(regionType) {
|
|
return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null;
|
|
}
|
|
|
|
var plan = {
|
|
layout: layout,
|
|
renderRegion: function (region, regionContent) {
|
|
var regionModule = getPlayerRegionModule(region.regionType);
|
|
if (regionModule && typeof regionModule.renderRegion === 'function') {
|
|
return regionModule.renderRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'image') {
|
|
return renderImageRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'video') {
|
|
return renderVideoRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'webpage') {
|
|
return renderWebpageRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'rtmp') {
|
|
return renderRtmpRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'rss') {
|
|
return renderRssRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'api') {
|
|
return renderApiRegion(region, regionContent);
|
|
}
|
|
if (region.regionType === 'html') {
|
|
return renderHtmlRegion(region, regionContent);
|
|
}
|
|
return renderTextRegion(region, regionContent);
|
|
}
|
|
};
|
|
|
|
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] || {};
|
|
const markup = plan.renderRegion(region, regionContent);
|
|
return decorateRegionMarkup(markup, {
|
|
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
|
});
|
|
}).join('') : '';
|
|
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);
|
|
}
|
|
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (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);
|
|
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
|
|
setPlayerCanvasDimensions(canvasSize.width, canvasSize.height);
|
|
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
|
|
}
|
|
|
|
// Render the shared slide shell around slide-specific inner content.
|
|
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
|
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
|
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
|
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
|
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + 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 : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
|
}
|
|
|
|
function restorePlayerCanvasDimensions(width, height) {
|
|
if (!document || !document.documentElement) {
|
|
return;
|
|
}
|
|
|
|
var style = document.documentElement.style;
|
|
if (width) {
|
|
style.setProperty('--player-canvas-width', width);
|
|
} else {
|
|
style.removeProperty('--player-canvas-width');
|
|
}
|
|
|
|
if (height) {
|
|
style.setProperty('--player-canvas-height', height);
|
|
} else {
|
|
style.removeProperty('--player-canvas-height');
|
|
}
|
|
}
|
|
|
|
function primeSlideMarkup(slide) {
|
|
if (!slide) {
|
|
return '';
|
|
}
|
|
|
|
var cachedMarkup = getCachedSlideMarkup(slide);
|
|
if (cachedMarkup) {
|
|
return cachedMarkup;
|
|
}
|
|
|
|
var previousWidth = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-width') || '') : '';
|
|
var previousHeight = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-height') || '') : '';
|
|
|
|
try {
|
|
return buildSlideMarkupForState(slide, false);
|
|
} finally {
|
|
restorePlayerCanvasDimensions(previousWidth.trim(), previousHeight.trim());
|
|
}
|
|
}
|
|
|
|
function notifyVideoRegionSourceReady() {
|
|
if (typeof videoRegionRenderVersion === 'number') {
|
|
videoRegionRenderVersion += 1;
|
|
}
|
|
slideMarkupCache = Object.create(null);
|
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
|
showCurrent({ skipFade: true });
|
|
}
|
|
}
|
|
|
|
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
|
|
|
|
// 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 buildSlideMarkupForState(slide, updateCurrentState) {
|
|
if (updateCurrentState) {
|
|
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;
|
|
}
|
|
|
|
function buildSlideMarkup(slide) {
|
|
return buildSlideMarkupForState(slide, true);
|
|
}
|