// 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) }; } const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', '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'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], tr: ['class', 'style'], ul: ['class', 'style'] }; const allowed = allowedAttributes[tagName] || []; if (!allowed.length) { 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(value) + '"'); return ''; }); return attrs.join(''); } // Remove unsafe markup while preserving richer CKEditor formatting. function sanitizeRichText(html) { var output = String(html || ''); output = output.replace(//gi, ''); output = output.replace(//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 ''; } 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 '' + sanitizeRichText(block.data.text || '') + ''; } 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('') + ''; } if (block.type === 'delimiter') { return '
'; } if (block.type === 'code') { return '
' + escapeHtml(block.data.code || '') + '
'; } if (block.type === 'table') { return renderEditorJsTable(block.data); } if (block.type === 'paragraph') { return '

' + sanitizeRichText(block.data.text || '') + '

'; } 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('') + '' : ''; return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; } return '
  • ' + sanitizeRichText(item || '') + '
  • '; } // 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 '' + cells.map(function (cell) { return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; }).join('') + ''; }).join(''); return '' + tableRows + '
    '; } // 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); } // 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; } // 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, baseStyle: baseStyle, pixelWidth: pixelWidth, pixelHeight: pixelHeight, 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 ? '' : '', backgroundColor: template.background_color || '#111111', regions: regions }; templateLayoutCache[cacheKey] = layout; return layout; } // Build a dark backdrop style for template and media canvases. function buildBackdropStyle(backgroundColor, backgroundImagePath) { var color = String(backgroundColor || '#111111').trim() || '#111111'; var style = 'background-color:' + escapeHtml(color) + ';'; var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))'; if (backgroundImagePath) { style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");'; style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;'; return style; } style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;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); var plan = { layout: layout, renderRegion: function (region, regionContent) { if (region.regionType === 'image') { return renderImageRegion(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] || {}; return plan.renderRegion(region, regionContent); }).join('') : ''; const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : ''; return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '
    ' + (layout ? layout.background : '') + regions + '
    '); } // Media rendering helpers. // Build the direct media element for a slide. function renderMediaSlideContent(slide) { if (slide.kind === 'image') { return '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) : ''; 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 ? '
    ' + escapeHtml(slide.body) + '
    ' : ''; const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas'; const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || ''); return '
    ' + innerHtml + body + '
    '; } // 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; }