Release v2.2.0

This commit is contained in:
2026-08-01 21:41:44 +01:00
parent c643d2fb07
commit d6417b667c
673 changed files with 146752 additions and 7389 deletions
@@ -0,0 +1,55 @@
<style>
.player-announcement--fullscreen {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
color: {{FOREGROUND}};
background: {{ACCENT}};
--announcement-canvas-min: min(var(--player-canvas-width, 100vw), var(--player-canvas-height, 100vh));
}
.player-announcement--fullscreen .player-announcement__body {
width: 100%;
height: 100%;
display: grid;
place-items: center;
padding: clamp(36px, calc(var(--announcement-canvas-min) * 0.06), 96px);
box-sizing: border-box;
}
.player-announcement--fullscreen .player-announcement__stack {
display: grid;
justify-items: center;
align-content: center;
gap: clamp(12px, calc(var(--announcement-canvas-min) * 0.015), 24px);
text-align: center;
}
.player-announcement--fullscreen .player-announcement__icon {
width: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
height: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
display: grid;
place-items: center;
font-size: clamp(4rem, calc(var(--announcement-canvas-min) * 0.16), 10rem);
justify-content: center;
}
.player-announcement--fullscreen .player-announcement__message {
width: min(100%, calc(var(--announcement-canvas-min) * 1.35));
text-align: center;
font-size: clamp(2rem, calc(var(--announcement-canvas-min) * 0.065), 8rem);
font-weight: 900;
line-height: 1.08;
}
</style>
<section class="player-announcement player-announcement--fullscreen player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__stack">
<div class="player-announcement__icon player-announcement__icon--fullscreen" aria-hidden="true"><i class="bi bi-{{ICON}}"></i></div>
<div class="player-announcement__message">{{MESSAGE}}</div>
</div>
</div>
</section>
@@ -0,0 +1,197 @@
<style>
.player-announcement--lower-third {
position: relative;
width: 100%;
height: auto;
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
display: flex;
align-self: flex-end;
align-items: stretch;
overflow: hidden;
color: var(--announcement-foreground, #fff);
background: var(--announcement-accent, #0d6efd);
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
}
.player-announcement--lower-third .player-announcement__body {
position: relative;
width: 100%;
height: auto;
min-height: 0;
display: flex;
align-items: stretch;
overflow: hidden;
border-radius: inherit;
}
.player-announcement--lower-third .player-announcement__icon {
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
var(--announcement-accent, #0d6efd);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
}
.player-announcement--lower-third .player-announcement__icon i {
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.player-announcement--lower-third .player-announcement__content {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
var(--announcement-accent, #0d6efd);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.10),
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
}
.player-announcement--lower-third .player-announcement__content::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
opacity: 0.75;
}
.player-announcement--lower-third .player-announcement__kicker {
position: relative;
z-index: 1;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.player-announcement--lower-third .player-announcement__message {
position: relative;
z-index: 1;
width: 100%;
color: #fff;
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
font-weight: 700;
line-height: 1.05;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: hidden;
white-space: nowrap;
}
.player-announcement--lower-third .player-announcement__message-track {
display: inline-flex;
align-items: center;
gap: 2.5rem;
min-width: 100%;
width: max-content;
will-change: transform;
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
.player-announcement--lower-third .player-announcement__message-text {
display: inline-block;
flex: 0 0 auto;
}
@media (orientation: portrait) {
.player-announcement--lower-third {
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
}
.player-announcement--lower-third .player-announcement__icon {
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
}
.player-announcement--lower-third .player-announcement__icon i {
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
}
.player-announcement--lower-third .player-announcement__content {
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
}
.player-announcement--lower-third .player-announcement__kicker {
margin-bottom: 2px;
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
}
.player-announcement--lower-third .player-announcement__message {
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
}
.player-announcement--lower-third .player-announcement__message-track {
gap: 1.25rem;
}
}
@keyframes lower-third-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
</style>
<section class="player-announcement player-announcement--lower-third player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__icon" aria-hidden="true">
<i class="bi bi-{{ICON}}"></i>
</div>
<div class="player-announcement__content">
<div class="player-announcement__message">
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
<span class="player-announcement__message-text">{{MESSAGE}}</span>
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
</span>
</div>
</div>
</div>
</section>
<script>
(function () {
var currentScript = document.currentScript;
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--lower-third')
? currentScript.previousElementSibling
: null;
if (!root) {
return;
}
var track = root.querySelector('.player-announcement__message-track');
if (!track) {
return;
}
if (track.dataset.marqueeMeasured === 'true') {
return;
}
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
var durationSeconds = Math.max(8, singlePassWidth / rate);
var durationValue = durationSeconds.toFixed(2) + 's';
track.dataset.marqueeMeasured = 'true';
track.style.setProperty('--announcement-scroll-duration', durationValue);
track.style.animationDuration = durationValue;
}());
</script>
@@ -0,0 +1,199 @@
<style>
.player-announcement--top-banner {
position: absolute;
inset: 0 0 auto 0;
width: 100%;
height: auto;
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
display: flex;
align-self: flex-start;
align-items: stretch;
overflow: hidden;
color: var(--announcement-foreground, #fff);
background: var(--announcement-accent, #0d6efd);
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
z-index: 1;
}
.player-announcement--top-banner .player-announcement__body {
position: relative;
width: 100%;
height: auto;
min-height: 0;
display: flex;
align-items: stretch;
overflow: hidden;
border-radius: inherit;
}
.player-announcement--top-banner .player-announcement__icon {
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
var(--announcement-accent, #0d6efd);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
}
.player-announcement--top-banner .player-announcement__icon i {
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.player-announcement--top-banner .player-announcement__content {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
var(--announcement-accent, #0d6efd);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.10),
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
}
.player-announcement--top-banner .player-announcement__content::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
opacity: 0.75;
}
.player-announcement--top-banner .player-announcement__kicker {
position: relative;
z-index: 1;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.player-announcement--top-banner .player-announcement__message {
position: relative;
z-index: 1;
width: 100%;
color: #fff;
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
font-weight: 700;
line-height: 1.05;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: hidden;
white-space: nowrap;
}
.player-announcement--top-banner .player-announcement__message-track {
display: inline-flex;
align-items: center;
gap: 2.5rem;
min-width: 100%;
width: max-content;
will-change: transform;
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
.player-announcement--top-banner .player-announcement__message-text {
display: inline-block;
flex: 0 0 auto;
}
@media (orientation: portrait) {
.player-announcement--top-banner {
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
}
.player-announcement--top-banner .player-announcement__icon {
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
}
.player-announcement--top-banner .player-announcement__icon i {
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
}
.player-announcement--top-banner .player-announcement__content {
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
}
.player-announcement--top-banner .player-announcement__kicker {
margin-bottom: 2px;
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
}
.player-announcement--top-banner .player-announcement__message {
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
}
.player-announcement--top-banner .player-announcement__message-track {
gap: 1.25rem;
}
}
@keyframes top-banner-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
</style>
<section class="player-announcement player-announcement--top-banner player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__icon" aria-hidden="true">
<i class="bi bi-{{ICON}}"></i>
</div>
<div class="player-announcement__content">
<div class="player-announcement__message">
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
<span class="player-announcement__message-text">{{MESSAGE}}</span>
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
</span>
</div>
</div>
</div>
</section>
<script>
(function () {
var currentScript = document.currentScript;
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--top-banner')
? currentScript.previousElementSibling
: null;
if (!root) {
return;
}
var track = root.querySelector('.player-announcement__message-track');
if (!track) {
return;
}
if (track.dataset.marqueeMeasured === 'true') {
return;
}
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
var durationSeconds = Math.max(8, singlePassWidth / rate);
var durationValue = durationSeconds.toFixed(2) + 's';
track.dataset.marqueeMeasured = 'true';
track.style.setProperty('--announcement-scroll-duration', durationValue);
track.style.animationDuration = durationValue;
}());
</script>
+2
View File
@@ -1,3 +1,5 @@
// RTMP stream session management and ffmpeg probe orchestration.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
+83 -7
View File
@@ -1,5 +1,7 @@
const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check');
const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth');
// Player onboarding routes and signup flow helpers.
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
@@ -10,8 +12,8 @@ function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function getPublicBaseUrl(req) {
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
function getPublicBaseUrl(req, configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
@@ -40,6 +42,15 @@ function getOnboardingLimitKey(req, deviceId) {
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
}
function getPlayerPublicBaseUrl(req, configuredUrl) {
return getPublicBaseUrl(req, configuredUrl);
}
function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function clearOnboardingSignupAttempts() {
if (onboardingSignupAttempts.size > 1000) {
onboardingSignupAttempts.clear();
@@ -121,6 +132,62 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
});
}
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
if (!normalizedDeviceId) {
return null;
}
await pool.query(
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`,
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null]
);
return {
device_id: normalizedDeviceId,
public_base_url: normalizedPublicBaseUrl || null,
internal_base_url: normalizedInternalBaseUrl || null
};
}
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId || !normalizedScreenSlug) {
return null;
}
const [screenRows] = await pool.query('SELECT id FROM d_screens WHERE slug = ? LIMIT 1', [normalizedScreenSlug]);
if (!screenRows.length) {
return null;
}
const screenId = Number(screenRows[0].id);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Current model: every screen binds to the shared player row '1'.
// If we introduce multiple players, resolve the correct player row here instead of hardcoding it.
await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
return {
screen_id: screenId,
screen_slug: normalizedScreenSlug,
player_id: '1'
};
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
@@ -184,9 +251,13 @@ function registerPlayerOnboardingRoutes(app, options) {
res.send(common.renderPlayerOnboardingLandingPage());
});
app.get('/onboard', function (req, res) {
app.get('/onboard', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
try {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/status', function (req, res, next) {
@@ -231,7 +302,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
@@ -262,6 +333,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
await bindPlayerToScreen(pool, deviceId, screenSlug);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
@@ -280,8 +352,12 @@ function registerPlayerOnboardingRoutes(app, options) {
module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
+2
View File
@@ -1,3 +1,5 @@
// File-backed storage for onboarding device mappings.
const fs = require('fs');
const path = require('path');
+261
View File
@@ -0,0 +1,261 @@
// Announcement overlay polling and rendering helpers.
(function () {
var announcementLayer = null;
var announcementRefreshTimer = null;
var announcementPollTimer = null;
var announcementSocket = null;
var announcementReconnectTimer = null;
var announcementRequestId = 0;
var announcementEtag = '';
var announcementHideTimer = null;
var announcementRevealTimer = null;
var announcementFadeDurationMs = 220;
var announcementSocketPath = '/ws/screens/' + encodeURIComponent(window.slug || '') + '/announcements';
function normalizeAnnouncementType(value) {
var normalized = String(value || '').trim().toLowerCase();
if (normalized === 'center-card') {
return 'fullscreen';
}
if (['lower-third', 'fullscreen', 'top-banner'].indexOf(normalized) !== -1) {
return normalized;
}
return 'lower-third';
}
function getAnnouncementLayer() {
if (announcementLayer) {
return announcementLayer;
}
announcementLayer = document.getElementById('player-announcement-layer');
if (!announcementLayer) {
announcementLayer = document.createElement('div');
announcementLayer.id = 'player-announcement-layer';
announcementLayer.className = 'player-announcement-layer';
announcementLayer.setAttribute('aria-live', 'polite');
announcementLayer.setAttribute('aria-atomic', 'true');
document.body.appendChild(announcementLayer);
}
return announcementLayer;
}
function clearAnnouncementHideTimer() {
if (announcementHideTimer) {
window.clearTimeout(announcementHideTimer);
announcementHideTimer = null;
}
}
function clearAnnouncementRevealTimer() {
if (announcementRevealTimer) {
window.cancelAnimationFrame(announcementRevealTimer);
announcementRevealTimer = null;
}
}
function activateInjectedScripts(layer) {
if (!layer) {
return;
}
var scripts = layer.querySelectorAll('script');
scripts.forEach(function (originalScript) {
var replacement = document.createElement('script');
Array.prototype.slice.call(originalScript.attributes || []).forEach(function (attribute) {
replacement.setAttribute(attribute.name, attribute.value);
});
replacement.text = originalScript.textContent || '';
originalScript.parentNode.replaceChild(replacement, originalScript);
});
}
function hideAnnouncement() {
var layer = getAnnouncementLayer();
clearAnnouncementRevealTimer();
clearAnnouncementHideTimer();
layer.classList.add('is-closing');
layer.classList.remove('is-visible');
announcementHideTimer = window.setTimeout(function () {
layer.innerHTML = '';
layer.hidden = true;
layer.classList.remove('is-closing');
announcementHideTimer = null;
}, announcementFadeDurationMs);
}
function renderAnnouncement(announcement) {
var layer = getAnnouncementLayer();
if (!announcement || !announcement.message) {
hideAnnouncement();
return;
}
var type = normalizeAnnouncementType(announcement.announcement_type);
clearAnnouncementRevealTimer();
clearAnnouncementHideTimer();
layer.hidden = false;
layer.className = 'player-announcement-layer player-announcement-layer--' + type;
layer.classList.remove('is-closing');
layer.innerHTML = window.renderPlayerAnnouncementMarkup ? window.renderPlayerAnnouncementMarkup(announcement) : '';
activateInjectedScripts(layer);
announcementRevealTimer = window.requestAnimationFrame(function () {
announcementRevealTimer = null;
layer.classList.add('is-visible');
});
}
function scheduleAnnouncementRefresh(delayMs) {
if (announcementRefreshTimer) {
window.clearTimeout(announcementRefreshTimer);
}
announcementRefreshTimer = window.setTimeout(function () {
announcementRefreshTimer = null;
void refreshAnnouncementOverlay();
}, Math.max(0, Number(delayMs || 0)));
}
async function refreshAnnouncementOverlay() {
var requestId = ++announcementRequestId;
var request = new XMLHttpRequest();
var url = window.location.origin + '/api/screens/' + encodeURIComponent(window.slug || '') + '/announcement?ts=' + Date.now();
request.open('GET', url, true);
request.timeout = 2500;
if (window.__pulsePageAuthToken) {
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
}
if (announcementEtag) {
request.setRequestHeader('If-None-Match', announcementEtag);
}
request.onreadystatechange = function () {
if (request.readyState !== 4 || requestId !== announcementRequestId) {
return;
}
if (request.status === 304) {
return;
}
if (request.status < 200 || request.status >= 300) {
scheduleAnnouncementRefresh(15000);
return;
}
try {
var responseEtag = String(request.getResponseHeader('ETag') || '').trim();
var data = JSON.parse(request.responseText || '{}');
var announcement = data && data.announcement ? data.announcement : null;
if (responseEtag) {
announcementEtag = responseEtag;
}
if (!announcement) {
hideAnnouncement();
} else {
renderAnnouncement(announcement);
}
} catch (_error) {
scheduleAnnouncementRefresh(15000);
}
};
request.onerror = function () {
scheduleAnnouncementRefresh(15000);
};
request.ontimeout = function () {
scheduleAnnouncementRefresh(15000);
};
request.send();
}
function scheduleAnnouncementPolling() {
if (announcementPollTimer) {
window.clearInterval(announcementPollTimer);
}
announcementPollTimer = window.setInterval(function () {
void refreshAnnouncementOverlay();
}, 15000);
}
function scheduleAnnouncementReconnect() {
if (announcementReconnectTimer) {
return;
}
announcementReconnectTimer = window.setTimeout(function () {
announcementReconnectTimer = null;
connectAnnouncementSocket();
}, 5000);
}
function handleAnnouncementSocketMessage(rawMessage) {
var payload;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || payload.type !== 'announcement-refresh') {
return;
}
void refreshAnnouncementOverlay();
}
function connectAnnouncementSocket() {
if (!window.WebSocket) {
return;
}
if (announcementSocket && (announcementSocket.readyState === WebSocket.OPEN || announcementSocket.readyState === WebSocket.CONNECTING)) {
return;
}
var socketUrl = new URL(announcementSocketPath, window.location.origin);
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
announcementSocket = socket;
socket.onopen = function () {
void refreshAnnouncementOverlay();
};
socket.onmessage = function (event) {
handleAnnouncementSocketMessage(event.data);
};
socket.onclose = function () {
announcementSocket = null;
scheduleAnnouncementReconnect();
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore socket close errors
}
};
}
window.refreshAnnouncementOverlay = refreshAnnouncementOverlay;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
void refreshAnnouncementOverlay();
scheduleAnnouncementPolling();
connectAnnouncementSocket();
}, { once: true });
} else {
void refreshAnnouncementOverlay();
scheduleAnnouncementPolling();
connectAnnouncementSocket();
}
window.addEventListener('focus', function () {
void refreshAnnouncementOverlay();
});
})();
+34
View File
@@ -1,7 +1,41 @@
<script>
const slug = {{SLUG_JSON}};
const initialData = {{INITIAL_DATA_JSON}};
window.slug = slug;
window.initialData = initialData;
const app = document.getElementById('app');
(function () {
var root = window;
var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
function normalizeType(type) {
return String(type || '').trim().toLowerCase();
}
function register(type, definition) {
registry[normalizeType(type)] = definition || {};
return registry[normalizeType(type)];
}
function get(type) {
return registry[normalizeType(type)] || null;
}
function list() {
return Object.keys(registry).map(function (type) {
return {
type: type,
definition: registry[type] || {}
};
});
}
root.pulsePlayerRegionTypes = {
register: register,
get: get,
list: list
};
}());
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
let currentPlaylistSignature = '';
let currentPlaylistEtag = '';
+3 -1
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/css/player.css" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
{{{STYLESHEETS}}}
</head>
<body class="{{BODY_CLASS}}">
{{{BODY}}}
+3 -1
View File
@@ -1,3 +1,5 @@
// Player playlist assembly, snapshot persistence, and playlist revision helpers.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
@@ -101,7 +103,7 @@ function createPlayerPlaylistService(options) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
+58 -19
View File
@@ -220,6 +220,30 @@ body.screen-blackout #app {
opacity: 0;
}
.player-announcement-layer {
position: fixed;
left: 50%;
top: 50%;
width: var(--player-canvas-width, 100vw);
height: var(--player-canvas-height, 100vh);
max-width: 100vw;
max-height: 100vh;
transform: translate(-50%, -50%);
z-index: 9999;
pointer-events: none;
display: flex;
opacity: 0;
transition: opacity 220ms ease;
}
.player-announcement-layer.is-visible {
opacity: 1;
}
.player-announcement-layer.is-closing {
opacity: 0;
}
.slide {
width: 100%;
height: 100%;
@@ -312,7 +336,9 @@ body.screen-blackout #app {
box-sizing: border-box;
}
.template-region.text {
.template-region.text,
.template-region.api,
.template-region.rss {
color: #fff;
display: block;
padding: 0;
@@ -322,38 +348,51 @@ body.screen-blackout #app {
line-height: 1.35;
}
.template-region.text .template-region-text-scale {
.template-region .template-region-text-scale {
display: block;
transform-origin: top left;
overflow: hidden;
}
.template-region.text .template-region-text-scale > * {
margin: 0;
.template-region .template-region-text-scale > * {
margin: 1em 0;
}
.template-region.text .template-region-text-scale > * + * {
margin-top: 0.5em;
.template-region .template-region-text-scale > *:first-child {
margin-top: 0;
}
.template-region.text .template-region-text-scale ul,
.template-region.text .template-region-text-scale ol {
.template-region .template-region-text-scale > *:last-child {
margin-bottom: 1em;
}
.template-region .template-region-text-scale ul,
.template-region .template-region-text-scale ol {
padding-left: 1.2em;
}
.template-region.text .template-region-text-scale code {
.template-region .template-region-text-scale code {
white-space: pre-wrap;
}
.template-region.text > * {
.template-region.text > *,
.template-region.api > *,
.template-region.rss > * {
margin: 0;
}
.template-region.text > * + * {
.template-region.text > * + *,
.template-region.api > * + *,
.template-region.rss > * + * {
margin-top: 0.5em;
}
.template-region.text ul,
.template-region.text ol {
.template-region.text ol,
.template-region.api ul,
.template-region.api ol,
.template-region.rss ul,
.template-region.rss ol {
padding-left: 1.2em;
}
@@ -424,26 +463,26 @@ body.screen-blackout #app {
font-size: 0.9rem;
}
.template-region.text table,
.template-region.text th,
.template-region.text td {
.template-region table,
.template-region th,
.template-region td {
border: 1px solid rgba(255, 255, 255, 0.35);
border-collapse: collapse;
}
.template-region.text table {
.template-region table {
width: 100%;
border-spacing: 0;
}
.template-region.text th,
.template-region.text td {
.template-region th,
.template-region td {
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.template-region.text th {
.template-region th {
font-weight: 700;
}
@@ -0,0 +1,80 @@
(function () {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeAnnouncementType(value) {
var normalized = String(value || '').trim().toLowerCase();
if (normalized === 'center-card') {
return 'fullscreen';
}
return ANNOUNCEMENT_TYPES.indexOf(normalized) !== -1 ? normalized : 'lower-third';
}
function normalizeAnnouncementColor(value) {
var normalized = String(value || '').trim().toLowerCase();
if (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'].indexOf(normalized) !== -1) {
return normalized;
}
return 'primary';
}
function normalizeAnnouncementIcon(value) {
var normalized = String(value || '').trim().toLowerCase();
return ANNOUNCEMENT_ICONS.indexOf(normalized) !== -1 ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
}
function getAnnouncementColorTokens(colorKey) {
var tokens = {
primary: { accent: '#0d6efd', foreground: '#ffffff', glow: 'rgba(13, 110, 253, 0.32)' },
secondary: { accent: '#6c757d', foreground: '#ffffff', glow: 'rgba(108, 117, 125, 0.30)' },
success: { accent: '#198754', foreground: '#ffffff', glow: 'rgba(25, 135, 84, 0.34)' },
info: { accent: '#0dcaf0', foreground: '#052c36', glow: 'rgba(13, 202, 240, 0.28)' },
warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' },
danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' },
dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' },
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' }
};
return tokens[colorKey] || tokens.primary;
}
function replaceTemplate(template, replacements) {
return String(template || '').replace(/{{([A-Z0-9_]+)}}/g, function (_match, key) {
return Object.prototype.hasOwnProperty.call(replacements, key) ? String(replacements[key]) : '';
});
}
function renderPlayerAnnouncementMarkup(announcement) {
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
var colorTokens = getAnnouncementColorTokens(colorKey);
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key);
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/\n/g, '<br />');
var titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
var templates = window.playerAnnouncementTemplates || {};
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
var template = templates[templateName] || '';
return replaceTemplate(template, {
ACCENT: colorTokens.accent,
FOREGROUND: colorTokens.foreground,
GLOW: colorTokens.glow,
ICON: iconKey,
COLOR_KEY: colorKey,
TITLE: titleText,
MESSAGE: text
});
}
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
})();
@@ -106,9 +106,45 @@ function clearSlideTransitionTimer() {
}
}
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) {
clearSlideTransitionTimer();
destroyRegionInstances(app);
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
@@ -172,6 +208,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
initializeRegionInstances(app);
initializeRenderedVideoPlayback(app);
return app.firstElementChild;
}
@@ -224,6 +261,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell;
}
@@ -245,6 +283,8 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () {
@@ -389,6 +429,11 @@ function handleCommandMessage(rawMessage) {
case 'reload':
window.location.reload();
return;
case 'announcement-refresh':
if (typeof refreshAnnouncementOverlay === 'function') {
refreshAnnouncementOverlay();
}
return;
}
}
+39 -6
View File
@@ -53,6 +53,17 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
};
}
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');
}
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) {
@@ -114,7 +125,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return attrs.join('');
}
// Remove unsafe markup while preserving richer CKEditor formatting.
// Remove unsafe markup while preserving richer rich-text formatting.
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
@@ -202,6 +213,17 @@ function renderEditorJsTable(data) {
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') {
@@ -221,7 +243,7 @@ function renderEditorJsContent(value) {
} catch (_error) {
// fall through to legacy HTML rendering
}
return sanitizeRichText(raw);
return wrapRichTextParagraph(sanitizeRichText(raw));
}
// Parse string values that look like JSON.
@@ -415,15 +437,14 @@ function getTemplateLayout(template) {
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;';
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
return style;
}
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
return style;
}
@@ -446,9 +467,17 @@ function getTemplateRenderPlan(template) {
}
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);
}
@@ -489,6 +518,9 @@ function renderTemplateSlideMarkup(slide) {
return plan.renderRegion(region, regionContent);
}).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
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>');
}
@@ -506,6 +538,7 @@ 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);
}
+3 -1
View File
@@ -1,4 +1,6 @@
const CACHE_VERSION = 'v2';
// Service worker cache strategy for player pages, assets, media, and playlists.
const CACHE_VERSION = 'v36';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
+63 -26
View File
@@ -1,3 +1,8 @@
// API region helpers for resolving source data and nested values.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
function getApiSourceById(sourceId) {
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
var normalizedId = Number(sourceId || 0);
@@ -6,9 +11,29 @@ function getApiSourceById(sourceId) {
}) || null;
}
function getApiSourceItems(sourceId) {
function getApiSourceItemsPath(source, overridePath) {
if (overridePath === undefined || overridePath === null) {
return String(source && (source.items_path || source.itemsPath) || '').trim();
}
return String(overridePath || '').trim();
}
function getApiSourceItems(sourceId, itemsPathOverride) {
var source = getApiSourceById(sourceId);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
var current = responseJson;
String(itemsPath).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return Array.isArray(current) ? current : [];
}
if (Array.isArray(responseJson)) {
return responseJson;
}
@@ -24,38 +49,25 @@ function getApiSourceItems(sourceId) {
return responseJson ? [responseJson] : [];
}
function getApiItem(sourceId, itemNumber) {
var items = getApiSourceItems(sourceId);
function getApiItem(sourceId, itemNumber, itemsPathOverride) {
var items = getApiSourceItems(sourceId, itemsPathOverride);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function resolveApiPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function substituteApiVariables(html, item) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveApiPath(item, key || ''));
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
});
}
@@ -79,14 +91,35 @@ function getApiPreviewFallback(item) {
return summary.join('');
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderApiRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : '';
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber);
var item = getApiItem(sourceId, itemNumber, itemsPath);
var body = item ? substituteApiVariables(content, item) : '';
if (!body && item) {
body = getApiPreviewFallback(item);
@@ -97,4 +130,8 @@ function renderApiRegion(region, regionContent) {
}
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
}
registry.register('api', {
renderRegion: renderApiRegion
});
+9 -1
View File
@@ -1,3 +1,7 @@
// HTML region rendering with sandboxed iframe output.
var registry = window.pulsePlayerRegionTypes;
function renderHtmlRegionContent(value) {
var html = String(value || '').trim();
if (!html) {
@@ -8,4 +12,8 @@ function renderHtmlRegionContent(value) {
function renderHtmlRegion(region, regionContent) {
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
}
}
registry.register('html', {
renderRegion: renderHtmlRegion
});
+9 -1
View File
@@ -1,7 +1,15 @@
// Image region rendering for direct slide media references.
var registry = window.pulsePlayerRegionTypes;
function renderImageRegion(region, regionContent) {
var src = regionContent.value || '';
if (!String(src || '').trim()) {
return '';
}
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
}
}
registry.register('image', {
renderRegion: renderImageRegion
});
+43 -5
View File
@@ -1,3 +1,35 @@
// RSS region helpers for resolving feeds, items, and nested values.
var registry = window.pulsePlayerRegionTypes;
function getDefaultStyle() {
return {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function getRssFeedById(feedId) {
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
var normalizedId = Number(feedId || 0);
@@ -37,18 +69,19 @@ function substituteRssVariables(html, item) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
return escapeHtml(resolveRssPath(item, key || ''));
});
}
function renderRssRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var defaultStyle = getDefaultStyle();
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getRssFeedItem(feedId, itemNumber);
var body = item ? substituteRssVariables(content, item) : '';
if (!body && item) {
@@ -68,3 +101,8 @@ function renderRssRegion(region, regionContent) {
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
registry.register('rss', {
getDefaultStyle: getDefaultStyle,
renderRegion: renderRssRegion
});
+7 -1
View File
@@ -1,3 +1,5 @@
var registry = window.pulsePlayerRegionTypes;
function renderRtmpRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
@@ -822,4 +824,8 @@ function destroyRtmpRegions(root) {
video.__rtmpHls = null;
}
});
}
}
registry.register('rtmp', {
renderRegion: renderRtmpRegion
});
+43 -5
View File
@@ -1,13 +1,51 @@
// Text region rendering with font and color normalization.
var registry = window.pulsePlayerRegionTypes;
function getDefaultStyle() {
return {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderTextRegion(region, regionContent) {
var rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (!String(rawValue || '').trim()) {
return '';
}
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var defaultStyle = getDefaultStyle();
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = renderEditorJsContent(rawValue);
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
}
registry.register('text', {
getDefaultStyle: getDefaultStyle,
renderRegion: renderTextRegion
});
+337
View File
@@ -0,0 +1,337 @@
// Time/date region rendering and live updates.
var registry = window.pulsePlayerRegionTypes;
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
var timeDateFormatterCache = Object.create(null);
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeTagAttributes(tagName, attrText) {
var 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']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var 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') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function sanitizePreviewHtml(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 allowed = ['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'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeTagAttributes(name, String(match[3] || '')) + '>';
});
}
function getDefaultStyle() {
return {
font_family: DEFAULT_STYLE.font_family,
font_size: DEFAULT_STYLE.font_size,
font_color: DEFAULT_STYLE.font_color
};
}
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (_error) {
return 'UTC';
}
}
function resolveTimeZone(value) {
var raw = String(value || '').trim();
if (!raw) {
return getDefaultTimeZone();
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
return getDefaultTimeZone();
}
}
function getFormatter(key, options) {
if (!timeDateFormatterCache[key]) {
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
}
return timeDateFormatterCache[key];
}
function getFormattedParts(timeZone, date) {
var targetDate = date instanceof Date ? date : new Date();
var resolvedTimeZone = resolveTimeZone(timeZone);
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).formatToParts(targetDate);
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'long'
}).formatToParts(targetDate);
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'short'
}).formatToParts(targetDate);
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'long'
}).formatToParts(targetDate);
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'short'
}).formatToParts(targetDate);
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).formatToParts(targetDate);
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
timeZoneName: 'short'
}).formatToParts(targetDate);
function getPart(parts, type) {
var match = parts.find(function (part) {
return part && part.type === type;
});
return match ? String(match.value || '') : '';
}
function toTitleCase(value) {
return String(value || '').toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
return String(letter || '').toUpperCase();
});
}
return {
h: String(Number(getPart(numericParts, 'hour')) || 0),
hh: getPart(numericParts, 'hour'),
m: String(Number(getPart(numericParts, 'minute')) || 0),
mm: getPart(numericParts, 'minute'),
s: String(Number(getPart(numericParts, 'second')) || 0),
ss: getPart(numericParts, 'second'),
d: String(Number(getPart(numericParts, 'day')) || 0),
dd: getPart(numericParts, 'day'),
M: String(Number(getPart(numericParts, 'month')) || 0),
MM: getPart(numericParts, 'month'),
y: String(Number(getPart(numericParts, 'year')) || 0),
yyyy: getPart(numericParts, 'year'),
yy: String(Number(String(getPart(numericParts, 'year')).slice(-2)) || 0).padStart(2, '0'),
ddd: toTitleCase(getPart(weekdayShort, 'weekday')),
dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone,
tz_short: getPart(timezoneShort, 'timeZoneName'),
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
};
}
function resolveTimeDatePlaceholder(values, expression) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
}
var parsed = String(expression || '').trim();
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
}
function renderTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT;
var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDatePlaceholder(values, key) || '');
});
}
function getTextStyle(regionContent, region) {
var defaultStyle = getDefaultStyle();
return {
font_family: regionContent.font_family || region.fontFamily || defaultStyle.font_family,
font_size: regionContent.font_size || region.fontSize || defaultStyle.font_size,
font_color: regionContent.font_color || region.fontColor || defaultStyle.font_color
};
}
function renderTimeDateRegion(region, regionContent) {
var content = regionContent && typeof regionContent === 'object' ? regionContent : {};
var format = String(content.value !== undefined ? content.value : content.text || '').trim() || DEFAULT_FORMAT;
var timeZone = resolveTimeZone(content.timezone || content.time_zone || '');
var style = getTextStyle(content, region);
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
var renderedText = renderTemplate(format, timeZone, new Date());
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
}
function updateTimeDateRegion(element) {
if (!element) {
return;
}
var format = String(element.dataset.timeDateFormat || '').trim() || DEFAULT_FORMAT;
var timeZone = String(element.dataset.timeDateTimezone || '').trim();
var scaleWrapper = element.querySelector('.template-region-text-scale');
if (!scaleWrapper) {
return;
}
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
}
function scheduleTimeDateRegionUpdate(element) {
if (!element) {
return;
}
if (element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
function tick() {
if (!element.isConnected) {
if (element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
return;
}
updateTimeDateRegion(element);
element.__timeDateTimer = window.setTimeout(tick, Math.max(100, 1000 - (Date.now() % 1000)));
}
tick();
}
function destroyTimeDateRegions(root) {
if (!root) {
return;
}
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
if (element && element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
});
}
function initializeTimeDateRegions(root) {
if (!root) {
return;
}
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
scheduleTimeDateRegionUpdate(element);
});
}
registry.register('time-date', {
label: 'Time / Date',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 420, height: 180 };
},
renderRegion: renderTimeDateRegion,
initRegion: initializeTimeDateRegions,
destroyRegion: destroyTimeDateRegions
});
+8
View File
@@ -1,3 +1,5 @@
// Video region playback, retry, and source probe helpers.
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
@@ -142,4 +144,10 @@ function renderVideoRegion(region, regionContent) {
}
return '';
}
if (window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.register === 'function') {
window.pulsePlayerRegionTypes.register('video', {
renderRegion: renderVideoRegion
});
}
+9 -1
View File
@@ -1,7 +1,15 @@
// Webpage region rendering for embedded live URLs.
var registry = window.pulsePlayerRegionTypes;
function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
if (!url) {
return '';
}
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>';
}
}
registry.register('webpage', {
renderRegion: renderWebpageRegion
});
+109 -13
View File
@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const announcementIcons = require('#src/data/announcement-icons');
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
@@ -235,6 +236,17 @@ function renderEditorJsTable(data) {
return '<table class="ck-content-table">' + tableRows + '</table>';
}
function wrapRichTextParagraph(html) {
const 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>';
}
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
@@ -253,7 +265,7 @@ function renderEditorJsContent(value) {
} catch (_error) {
// fall through to legacy HTML rendering
}
return sanitizeRichText(raw);
return wrapRichTextParagraph(sanitizeRichText(raw));
}
function renderHtmlRegionContent(value) {
@@ -283,17 +295,13 @@ const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
const playerAnnouncementTemplatesScriptPath = path.join(__dirname, 'public', 'js', 'player-announcement-templates.js');
const playerAnnouncementLowerThirdTemplatePath = path.join(__dirname, 'announcement-templates', 'lower-third.template.html');
const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announcement-templates', 'top-banner.template.html');
const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html');
const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'video.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
path.join(__dirname, 'regions', 'rss.js'),
path.join(__dirname, 'regions', 'api.js'),
path.join(__dirname, 'regions', 'text.js')
];
const playerRegionScriptDir = path.join(__dirname, 'regions');
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
let playerPageTemplateCache = null;
@@ -303,6 +311,9 @@ let playerPagePlaylistScriptCache = null;
let playerPageCommandsScriptCache = null;
let playerPageRenderingScriptCache = null;
let playerPagePlaybackScriptCache = null;
let playerAnnouncementTemplatesScriptCache = null;
let playerAnnouncementTemplatesDataScriptCache = null;
let playerPageAnnouncementsScriptCache = null;
let playerPageScriptCache = null;
let playerRegionScriptsCache = null;
let playerOnboardingLandingScriptCache = null;
@@ -347,19 +358,100 @@ function getPlayerPagePlaybackScript() {
return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {}));
}
function getPlayerAnnouncementTemplatesScript() {
if (playerAnnouncementTemplatesScriptCache && playerAnnouncementTemplatesScriptCache.path === playerAnnouncementTemplatesScriptPath) {
return playerAnnouncementTemplatesScriptCache.value;
}
const value = fs.readFileSync(playerAnnouncementTemplatesScriptPath, 'utf8').trim();
playerAnnouncementTemplatesScriptCache = {
path: playerAnnouncementTemplatesScriptPath,
value: value
};
return value;
}
function getAnnouncementIconsDataScript() {
return [
'(function () {',
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';',
' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';',
'}());'
].join('\n');
}
function loadAnnouncementTemplate(filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}
function getPlayerAnnouncementTemplatesDataScript() {
const templatePaths = [
playerAnnouncementLowerThirdTemplatePath,
playerAnnouncementTopBannerTemplatePath,
playerAnnouncementFullscreenTemplatePath
];
const signature = templatePaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs;
}).join('|');
if (playerAnnouncementTemplatesDataScriptCache && playerAnnouncementTemplatesDataScriptCache.signature === signature) {
return playerAnnouncementTemplatesDataScriptCache.value;
}
const value = [
'(function () {',
' window.playerAnnouncementTemplates = {',
' lowerThird: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementLowerThirdTemplatePath)) + ',',
' topBanner: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementTopBannerTemplatePath)) + ',',
' fullscreen: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementFullscreenTemplatePath)),
' };',
'}());'
].join('\n');
playerAnnouncementTemplatesDataScriptCache = {
signature: signature,
value: value
};
return value;
}
function getPlayerPageAnnouncementsScript() {
return loadTemplate(playerPageAnnouncementsScriptPath, playerPageAnnouncementsScriptCache || (playerPageAnnouncementsScriptCache = {}));
}
function getPlayerPageScript() {
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
}
function getPlayerRegionScriptPaths() {
if (!fs.existsSync(playerRegionScriptDir)) {
return [];
}
return fs.readdirSync(playerRegionScriptDir, { withFileTypes: true })
.filter(function (entry) {
return entry.isFile() && entry.name.toLowerCase().endsWith('.js');
})
.map(function (entry) {
return path.join(playerRegionScriptDir, entry.name);
})
.sort(function (left, right) {
return left.localeCompare(right);
});
}
function getPlayerRegionScripts() {
const statSignature = playerRegionScriptPaths.map(function (filePath) {
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
const scriptPaths = [sharedPlaceholderUtilsPath].concat(playerRegionScriptPaths);
const statSignature = scriptPaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs;
}).join('|');
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
return playerRegionScriptsCache.value;
}
const value = playerRegionScriptPaths.map(function (filePath) {
const value = scriptPaths.map(function (filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}).join('\n\n');
playerRegionScriptsCache = {
@@ -403,6 +495,10 @@ module.exports = {
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
getAnnouncementIconsDataScript: getAnnouncementIconsDataScript,
getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript,
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
getPlayerPageScript: getPlayerPageScript,
getPlayerRegionScripts: getPlayerRegionScripts,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
+18 -2
View File
@@ -1,13 +1,23 @@
// Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const path = require('path');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
function renderPage(template, options) {
const stylesheetLinks = Array.isArray(options && options.stylesheets) ? options.stylesheets : [];
return template({
TITLE: options.title,
BODY_CLASS: options.bodyClass || '',
BODY: new Handlebars.SafeString(options.body || ''),
STYLESHEETS: new Handlebars.SafeString(stylesheetLinks.map(function (href) {
return `<link rel="stylesheet" href="${Handlebars.escapeExpression(href)}" />`;
}).join('')),
SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '')
});
}
@@ -109,6 +119,7 @@ function renderPlayerPage(slug, initialData) {
const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
@@ -118,26 +129,31 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, {
title: 'Screen ' + slug,
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
});
}
function renderPlayerOnboardingLandingPage() {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player',
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
});
}
function renderPlayerOnboardingFormPage(deviceId) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard screen',
bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
});
}
+52
View File
@@ -1,3 +1,5 @@
// Player route registration and filesystem-backed media helpers.
const fs = require('fs');
const express = require('express');
const path = require('path');
@@ -18,6 +20,9 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1';
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
@@ -75,6 +80,7 @@ function registerPlayerRoutes(app, options) {
}
app.use('/assets', express.static(assetDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
@@ -207,6 +213,13 @@ function registerPlayerRoutes(app, options) {
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
const { bindPlayerToScreen } = require('./onboarding');
if (playerIdentifier) {
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
.catch(function (error) {
console.error(error);
});
}
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
@@ -273,6 +286,45 @@ function registerPlayerRoutes(app, options) {
}
});
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const announcement = typeof common.fetchActiveAnnouncement === 'function'
? await common.fetchActiveAnnouncement(pool, req.params.slug)
: null;
const revision = announcement
? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':')
: 'none';
const etag = '"' + String(revision || 'none') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json({
announcement: announcement,
revision: revision
});
} catch (error) {
if (isTransientDbError(error)) {
return res.status(503).json({ error: 'Announcement state unavailable.' });
}
next(error);
}
});
app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) {
try {
const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function'
? playerRuntime.broadcastAnnouncementRefresh(req.params.slug)
: 0;
res.json({ ok: true, screenSlug: req.params.slug, sent: sent });
} catch (error) {
next(error);
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try {
const connections = playerRuntime.snapshotConnections(req.params.slug);
+108 -3
View File
@@ -1,8 +1,23 @@
// Player runtime state, websocket connections, and request-auth enforcement.
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
function normalizePlayerPublicBaseUrl(pageUrl) {
const value = String(pageUrl || '').trim();
if (!value) {
return null;
}
try {
return new URL(value).origin.replace(/\/$/, '');
} catch (_error) {
return null;
}
}
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
@@ -12,6 +27,7 @@ function createPlayerRuntime(options) {
};
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const announcementListenersBySlug = new Map();
const wss = new WebSocketServer({ noServer: true });
function normalizeClientIp(value) {
@@ -72,6 +88,29 @@ function createPlayerRuntime(options) {
}
}
function getAnnouncementListenerBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!announcementListenersBySlug.has(key)) {
announcementListenersBySlug.set(key, new Set());
}
return announcementListenersBySlug.get(key);
}
function removeAnnouncementListener(slug, socket) {
const key = String(slug || '').trim();
const bucket = announcementListenersBySlug.get(key);
if (!bucket) {
return;
}
bucket.delete(socket);
if (!bucket.size) {
announcementListenersBySlug.delete(key);
}
}
function buildClientLabel(connection) {
const clientName = String(connection.clientName || '').trim();
const clientId = String(connection.clientId || '').trim();
@@ -123,6 +162,7 @@ function createPlayerRuntime(options) {
userAgent: connection.userAgent || null,
viewport: connection.viewport || null,
page: connection.page || null,
playerPublicBaseUrl: connection.playerPublicBaseUrl || null,
currentSlide: connection.currentSlide || null,
paused: Boolean(connection.paused),
blackout: Boolean(connection.blackout),
@@ -178,6 +218,30 @@ function createPlayerRuntime(options) {
});
}
function broadcastAnnouncementRefresh(slug) {
const key = String(slug || '').trim();
const bucket = announcementListenersBySlug.get(key);
if (!bucket || !bucket.size) {
return 0;
}
const payload = JSON.stringify({
type: 'announcement-refresh',
slug: key,
sentAt: new Date().toISOString()
});
let sent = 0;
bucket.forEach(function (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(payload);
sent += 1;
}
});
return sent;
}
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
@@ -235,7 +299,9 @@ function createPlayerRuntime(options) {
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
if (!dashboardMatch && !playerMatch) {
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
if (!dashboardMatch && !playerMatch && !announcementMatch) {
socket.destroy();
return;
}
@@ -256,9 +322,18 @@ function createPlayerRuntime(options) {
}
}
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
if (announcementMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy();
return;
}
}
const slug = decodeURIComponent((dashboardMatch || playerMatch || announcementMatch)[1]);
wss.handleUpgrade(request, socket, head, function (ws) {
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
});
}
@@ -288,6 +363,30 @@ function createPlayerRuntime(options) {
return;
}
if (role === 'announcements') {
const listenerBucket = getAnnouncementListenerBucket(slug);
if (!listenerBucket) {
socket.close();
return;
}
listenerBucket.add(socket);
socket.send(JSON.stringify({
type: 'announcement-ready',
slug: String(slug || '').trim(),
sentAt: new Date().toISOString()
}));
socket.on('close', function () {
removeAnnouncementListener(slug, socket);
});
socket.on('error', function () {
removeAnnouncementListener(slug, socket);
});
return;
}
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
@@ -304,6 +403,7 @@ function createPlayerRuntime(options) {
page: null,
paused: false,
blackout: false,
playerPublicBaseUrl: null,
clientIp: forwardedFor || normalizedRemoteAddress,
remoteAddress: normalizedRemoteAddress,
label: forwardedFor || normalizedRemoteAddress || 'connected client',
@@ -341,6 +441,10 @@ function createPlayerRuntime(options) {
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page;
const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page);
if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) {
connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl;
}
connection.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
@@ -372,6 +476,7 @@ function createPlayerRuntime(options) {
return {
installWebsocket: installWebsocket,
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
+2
View File
@@ -1,3 +1,5 @@
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
function buildThumbnailPreviewData(slide) {
return {
thumbnailPreview: true,