Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5029eff6 | ||
|
|
37345f3949 | ||
|
|
ed8d51580e |
@@ -3,6 +3,7 @@ media/
|
||||
!src/web/lib/media/
|
||||
!src/web/lib/media/**
|
||||
docker-compose.dev.yml
|
||||
/dev-demo-seed.js
|
||||
.vscode/
|
||||
.env
|
||||
npm-debug.log*
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.5.2 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
|
||||
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
|
||||
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
|
||||
|
||||
## 2.5.1 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
|
||||
|
||||
### Changed
|
||||
|
||||
- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
|
||||
- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
|
||||
- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
|
||||
|
||||
## 2.5.0 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
|
||||
|
||||
### Changed
|
||||
|
||||
- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now receive the template region animation columns through the versioned migration path.
|
||||
|
||||
## 2.4.2 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
|
||||
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
|
||||
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_template_regions`
|
||||
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
||||
|
||||
|
||||
Generated
+9
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.1.0",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.5.1",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -755,6 +756,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/animate.css": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
|
||||
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.2",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
@@ -13,10 +13,12 @@
|
||||
"start:web": "node -r dotenv/config src/web.js",
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:web": "nodemon -r dotenv/config src/web.js",
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
+61
-2
@@ -1,6 +1,7 @@
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -23,6 +24,61 @@ function normalizeTemplateRegionLockRatio(value) {
|
||||
return rawRatio.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimation(value) {
|
||||
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
||||
return animationPresets.normalize(value);
|
||||
}
|
||||
|
||||
const allowed = new Set(
|
||||
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
|
||||
? animationPresets.allValues
|
||||
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
|
||||
);
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
return allowed.has(raw) ? raw : 'none';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach((key) => {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationConfig(value) {
|
||||
let raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
@@ -57,7 +113,7 @@ async function fetchTemplateById(pool, id) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = 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 = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
@@ -70,7 +126,7 @@ async function fetchTemplatesData(pool) {
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = 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 ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -86,6 +142,7 @@ function extractTemplateRegions(body) {
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
@@ -101,6 +158,7 @@ function extractTemplateRegions(body) {
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||
const animationJsons = readFormArray(body, 'region_animation_json[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
@@ -120,6 +178,7 @@ function extractTemplateRegions(body) {
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ async function bootstrapDatabase(pool) {
|
||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
||||
await pool.query(`
|
||||
INSERT INTO c_canvas_sizes (name, width, height) VALUES
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
|
||||
@@ -52,6 +52,7 @@ async function ensureSchema(pool, options) {
|
||||
label VARCHAR(255) NOT NULL,
|
||||
font_family VARCHAR(100) NULL,
|
||||
lock_ratio VARCHAR(20) NULL,
|
||||
animation_json JSON NULL,
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 100,
|
||||
|
||||
+52
-15
@@ -55,16 +55,7 @@ const VERSIONED_MIGRATIONS = [
|
||||
}
|
||||
|
||||
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
|
||||
const [screenPlayerFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'd_screens'
|
||||
AND CONSTRAINT_NAME = 'fk_screens_player'`
|
||||
);
|
||||
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
|
||||
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT');
|
||||
}
|
||||
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
|
||||
|
||||
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
|
||||
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
|
||||
@@ -107,9 +98,7 @@ const VERSIONED_MIGRATIONS = [
|
||||
INDEX idx_announcements_modified_at (modified_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
version: '2.4.0',
|
||||
@@ -217,6 +206,13 @@ const VERSIONED_MIGRATIONS = [
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.5.1',
|
||||
label: 'v2.5.1 template animation json schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function columnExists(pool, tableName, columnName) {
|
||||
@@ -238,29 +234,70 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
|
||||
}
|
||||
|
||||
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [rows] = await pool.query(
|
||||
const [existingFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND CONSTRAINT_NAME = ?`,
|
||||
[tableName, constraintName]
|
||||
[constraintName]
|
||||
);
|
||||
|
||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
const fallbackNames = [
|
||||
constraintName,
|
||||
tableName + '_' + columnName + '_fk',
|
||||
tableName + '_' + columnName + '_fk_2',
|
||||
tableName + '_' + columnName + '_fk_3'
|
||||
];
|
||||
|
||||
for (const candidateName of fallbackNames) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
@@ -144,10 +145,10 @@
|
||||
|
||||
@keyframes lower-third-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
@@ -146,10 +147,10 @@
|
||||
|
||||
@keyframes top-banner-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Announcement overlay polling and rendering helpers.
|
||||
|
||||
(function () {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
var announcementLayer = null;
|
||||
var announcementRefreshTimer = null;
|
||||
var announcementPollTimer = null;
|
||||
@@ -211,12 +215,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var socketUrl = new URL(announcementSocketPath, window.location.origin);
|
||||
if (window.__pulsePageAuthToken) {
|
||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
|
||||
var socket = new WebSocket(socketUrl.toString());
|
||||
var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
|
||||
announcementSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
|
||||
@@ -121,7 +121,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, 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, animation_json, 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;
|
||||
|
||||
@@ -22,6 +22,21 @@ body.onboarding-page #app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.thumbnail-preview *,
|
||||
body.thumbnail-preview *::before,
|
||||
body.thumbnail-preview *::after {
|
||||
animation: none !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body.thumbnail-preview .player-announcement-layer,
|
||||
body.thumbnail-preview .player-offline-banner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -199,6 +214,8 @@ body.onboarding-page #app {
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 560ms ease;
|
||||
will-change: opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-shell.is-visible {
|
||||
@@ -228,7 +245,8 @@ body.screen-blackout #app {
|
||||
height: var(--player-canvas-height, 100vh);
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
backface-visibility: hidden;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,8 @@ function getCurrentViewport() {
|
||||
};
|
||||
}
|
||||
|
||||
var slideOutroTimers = [];
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
function sendCommandState(currentSlide) {
|
||||
@@ -65,6 +67,56 @@ function clearSlideTimer() {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
clearSlideOutroTimer();
|
||||
}
|
||||
|
||||
// Cancel any pending outro triggers for the current slide.
|
||||
function clearSlideOutroTimer() {
|
||||
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
|
||||
slideOutroTimers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
slideOutroTimers.forEach(function (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
slideOutroTimers = [];
|
||||
}
|
||||
|
||||
// Return the rendered slide root that is currently on screen.
|
||||
function getCurrentSlideRoot() {
|
||||
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
|
||||
if (shells.length) {
|
||||
return shells[shells.length - 1];
|
||||
}
|
||||
return app && app.firstElementChild ? app.firstElementChild : app;
|
||||
}
|
||||
|
||||
// Schedule the outgoing slide animation for each region so it finishes before removal.
|
||||
function scheduleSlideOutro(holdDelayMs) {
|
||||
clearSlideOutroTimer();
|
||||
|
||||
var currentRoot = getCurrentSlideRoot();
|
||||
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
|
||||
if (!regionTimings.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
|
||||
slideOutroTimers = regionTimings.map(function (entry) {
|
||||
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
|
||||
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
|
||||
return window.setTimeout(function () {
|
||||
if (!entry || !entry.element || !entry.element.isConnected) {
|
||||
return;
|
||||
}
|
||||
playRegionAnimation(entry.element, 'outro');
|
||||
}, triggerDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule the next slide transition.
|
||||
@@ -72,10 +124,12 @@ function scheduleSlideAdvance(delayMs) {
|
||||
clearSlideTimer();
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
slideExpiresAt = Date.now() + holdDelayMs;
|
||||
scheduleSlideOutro(holdDelayMs);
|
||||
timer = window.setTimeout(function () {
|
||||
timer = null;
|
||||
slideExpiresAt = null;
|
||||
pausedRemainingMs = null;
|
||||
clearSlideOutroTimer();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (activeSlides.length < 2) {
|
||||
refresh();
|
||||
@@ -89,13 +143,10 @@ function scheduleSlideAdvance(delayMs) {
|
||||
}, holdDelayMs);
|
||||
}
|
||||
|
||||
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
|
||||
// Return the slide duration without shifting it for fade timing.
|
||||
function getSlideHoldDelay(delayMs) {
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
if (!currentPlaylistFadeBetweenSlides) {
|
||||
return holdDelayMs;
|
||||
}
|
||||
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
@@ -114,11 +165,19 @@ function getPlayerRegionModules() {
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function isThumbnailPreview() {
|
||||
return Boolean(window.__pulseThumbnailPreview);
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
@@ -208,8 +267,15 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(app);
|
||||
}
|
||||
initializeRenderedVideoPlayback(app);
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
window.requestAnimationFrame(function () {
|
||||
playRegionAnimations(app, 'intro');
|
||||
});
|
||||
}
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
@@ -277,14 +343,18 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(nextShell, 'intro');
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(nextShell);
|
||||
|
||||
}
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
@@ -456,12 +526,7 @@ function connectCommandSocket() {
|
||||
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var socketUrl = new URL(commandSocketPath, window.location.origin);
|
||||
if (window.__pulsePageAuthToken) {
|
||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
var socket = new WebSocket(socketUrl.toString());
|
||||
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
|
||||
@@ -53,6 +53,276 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return {
|
||||
preset: String(value.preset || fallbackPreset || 'none').trim(),
|
||||
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
|
||||
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
|
||||
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationConfig(value) {
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizePlayerAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
function clearRegionAnimationClasses(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var elements = [];
|
||||
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
|
||||
elements.push(root);
|
||||
}
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
elements.push(element);
|
||||
});
|
||||
|
||||
elements.forEach(function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.remove('animate__animated', 'animate__infinite');
|
||||
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
|
||||
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
|
||||
if (String(className || '').indexOf('animate__') === 0) {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
});
|
||||
element.style.removeProperty('--animate-duration');
|
||||
element.style.removeProperty('--animate-delay');
|
||||
element.style.removeProperty('--animate-repeat');
|
||||
});
|
||||
}
|
||||
|
||||
function isAttentionSeekerAnimation(preset) {
|
||||
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
|
||||
}
|
||||
|
||||
function applyAnimationStep(element, step, phase) {
|
||||
if (!element || !step) {
|
||||
return;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add('animate__animated', 'animate__' + preset);
|
||||
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
|
||||
if (Number(step.delay_ms || 0) > 0) {
|
||||
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
|
||||
} else {
|
||||
element.style.removeProperty('--animate-delay');
|
||||
}
|
||||
|
||||
if (phase === 'loop') {
|
||||
var repeatCount = Number(step.iterations);
|
||||
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
|
||||
repeatCount = 1;
|
||||
}
|
||||
if (repeatCount > 1) {
|
||||
element.classList.add('animate__repeat-1');
|
||||
}
|
||||
element.style.setProperty('--animate-repeat', String(repeatCount));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
|
||||
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
|
||||
}
|
||||
}
|
||||
|
||||
function getAnimationStepTimingMs(step) {
|
||||
if (!step) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var durationMs = Number(step.duration_ms);
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
durationMs = 1000;
|
||||
}
|
||||
|
||||
var delayMs = Number(step.delay_ms);
|
||||
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
||||
delayMs = 0;
|
||||
}
|
||||
|
||||
var iterations = Number(step.iterations);
|
||||
if (!Number.isFinite(iterations) || iterations < 1) {
|
||||
iterations = 1;
|
||||
}
|
||||
|
||||
return delayMs + (durationMs * iterations);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimingMs(root, phase) {
|
||||
var timings = getRegionAnimationPhaseTimings(root, phase);
|
||||
return timings.reduce(function (maxTimingMs, entry) {
|
||||
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimings(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var timings = [];
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
|
||||
if (timingMs > 0) {
|
||||
timings.push({
|
||||
element: element,
|
||||
timingMs: timingMs
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return timings;
|
||||
}
|
||||
|
||||
function playRegionAnimation(element, phase) {
|
||||
if (!element || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.animationPhase = normalizedPhase;
|
||||
clearRegionAnimationClasses(element);
|
||||
|
||||
if (normalizedPhase === 'outro') {
|
||||
applyAnimationStep(element, config.outro, 'outro');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
|
||||
applyAnimationStep(element, config.intro, 'intro');
|
||||
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
|
||||
element.addEventListener('animationend', function handleAnimationEnd(event) {
|
||||
if (event.target !== element) {
|
||||
return;
|
||||
}
|
||||
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
|
||||
return;
|
||||
}
|
||||
element.removeEventListener('animationend', handleAnimationEnd);
|
||||
clearRegionAnimationClasses(element);
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
}
|
||||
|
||||
function playRegionAnimations(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
|
||||
elements.forEach(function (element) {
|
||||
playRegionAnimation(element, normalizedPhase);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
@@ -474,6 +744,7 @@ function getTemplateLayout(template) {
|
||||
regionKey: region.region_key,
|
||||
regionType: region.region_type,
|
||||
label: region.label,
|
||||
animationJson: region.animation_json || null,
|
||||
baseStyle: baseStyle,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
@@ -578,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
|
||||
const layout = plan ? plan.layout : null;
|
||||
const regions = layout ? layout.regions.map(function (region) {
|
||||
const regionContent = content[region.regionKey] || {};
|
||||
return plan.renderRegion(region, regionContent);
|
||||
const markup = plan.renderRegion(region, regionContent);
|
||||
return decorateRegionMarkup(markup, {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||
if (layout) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -120,6 +120,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
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 bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
||||
const script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
@@ -128,9 +129,10 @@ function renderPlayerPage(slug, initialData) {
|
||||
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
bodyClass: bodyClass,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
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>'
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + 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>'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||
|
||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
const value = String(pageUrl || '').trim();
|
||||
@@ -43,6 +44,34 @@ function createPlayerRuntime(options) {
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||
if (!pair) {
|
||||
return cookies;
|
||||
}
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return cookies;
|
||||
}
|
||||
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||
if (name) {
|
||||
cookies[name] = value;
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readPageAuthToken(request) {
|
||||
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers && request.headers.cookie || '');
|
||||
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
|
||||
}
|
||||
|
||||
function getConnectionBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
@@ -314,7 +343,7 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
|
||||
if (playerMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
@@ -323,7 +352,7 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
|
||||
if (announcementMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
|
||||
+22
-1
@@ -246,12 +246,13 @@ function verifyRequestAuth(req) {
|
||||
return timingSafeEqualHex(expectedSignature, signature);
|
||||
}
|
||||
|
||||
function createPageFetchAuthScript(token) {
|
||||
function createPageFetchAuthScript(token, cookiePath) {
|
||||
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
||||
if (!normalizedToken) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedCookiePath = String(cookiePath || '').trim();
|
||||
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
||||
? Number(token.expiresAt)
|
||||
: null;
|
||||
@@ -262,6 +263,8 @@ function createPageFetchAuthScript(token) {
|
||||
' (function () {',
|
||||
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
||||
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
||||
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
|
||||
' var pageAuthCookieName = "pulse_page_auth";',
|
||||
' var pageAuthRenewalTimer = null;',
|
||||
' var pageAuthRenewalInFlight = null;',
|
||||
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
||||
@@ -287,11 +290,28 @@ function createPageFetchAuthScript(token) {
|
||||
' });',
|
||||
' }, delayMs);',
|
||||
' }',
|
||||
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
|
||||
' if (!pageAuthCookiePath) {',
|
||||
' return;',
|
||||
' }',
|
||||
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
|
||||
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
|
||||
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
|
||||
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
|
||||
' } else {',
|
||||
' cookieParts.push("Max-Age=0");',
|
||||
' }',
|
||||
' if (window.location.protocol === "https:") {',
|
||||
' cookieParts.push("Secure");',
|
||||
' }',
|
||||
' document.cookie = cookieParts.join("; ");',
|
||||
' }',
|
||||
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
||||
' pageAuthToken = String(nextToken || "").trim();',
|
||||
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' schedulePageAuthRenewal();',
|
||||
' }',
|
||||
' async function renewPageAuthToken() {',
|
||||
@@ -326,6 +346,7 @@ function createPageFetchAuthScript(token) {
|
||||
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' window.addEventListener("focus", function () {',
|
||||
' schedulePageAuthRenewal();',
|
||||
' });',
|
||||
|
||||
@@ -160,6 +160,7 @@ function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
@@ -190,6 +190,82 @@
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.template-preview-card .card-body {
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.template-preview-card.is-previewing {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.45);
|
||||
}
|
||||
|
||||
.template-preview-card.is-previewing .card-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.template-preview-card.is-previewing .card-title {
|
||||
position: relative;
|
||||
padding-right: 6.25rem;
|
||||
}
|
||||
|
||||
.template-preview-card.is-previewing .card-title::after {
|
||||
content: ' previewing';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(100% + 0.35rem);
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.template-preview-card .btn-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.template-preview-card .btn-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-preview-card[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .designer-stage {
|
||||
box-shadow: none !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .designer-overlay {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .designer-rect {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .region-item,
|
||||
.template-designer-form--previewing .template-details-card,
|
||||
.template-designer-form--previewing .template-options-card,
|
||||
.template-designer-form--previewing .region-info-card {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .region-item [disabled],
|
||||
.template-designer-form--previewing .template-details-card [disabled],
|
||||
.template-designer-form--previewing .template-options-card [disabled],
|
||||
.template-designer-form--previewing .region-info-card [disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.announcement-icon-picker {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1401,7 +1477,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 18rem;
|
||||
/* overflow: hidden; */
|
||||
overflow: visible;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bs-body-bg);
|
||||
@@ -1431,11 +1507,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
.designer-overlay {
|
||||
z-index: 2;
|
||||
cursor: default;
|
||||
--designer-overlay-scale: 1;
|
||||
}
|
||||
|
||||
.designer-rect {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(13, 110, 253, 0.95);
|
||||
overflow: visible;
|
||||
z-index: 3;
|
||||
border: 0;
|
||||
outline: 2px solid rgba(13, 110, 253, 0.95);
|
||||
outline-offset: -2px;
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
box-sizing: border-box;
|
||||
border-radius: 0;
|
||||
@@ -1445,20 +1526,20 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
|
||||
.designer-rect.selected {
|
||||
border-color: rgba(25, 135, 84, 1);
|
||||
outline-color: rgba(25, 135, 84, 1);
|
||||
background: rgba(25, 135, 84, 0.14);
|
||||
}
|
||||
|
||||
.designer-rect-label {
|
||||
position: absolute;
|
||||
left: 0.4rem;
|
||||
top: 0.35rem;
|
||||
left: calc(6px * var(--designer-overlay-scale, 1));
|
||||
top: calc(6px * var(--designer-overlay-scale, 1));
|
||||
max-width: 100%;
|
||||
padding: 0.15rem 0.45rem;
|
||||
padding: calc(2px * var(--designer-overlay-scale, 1)) calc(7px * var(--designer-overlay-scale, 1));
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 37, 41, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-size: calc(0.75rem * var(--designer-overlay-scale, 1));
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1471,8 +1552,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
z-index: 4;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #fff;
|
||||
background: rgba(13, 110, 253, 1);
|
||||
@@ -1482,28 +1565,28 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
.resize-handle.nw {
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate(-40%, -40%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.ne {
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
transform: translate(40%, -40%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.sw {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
transform: translate(-40%, 40%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.se {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: translate(50%, 50%);
|
||||
transform: translate(40%, 40%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
@@ -1641,6 +1724,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.region-field-grid--animation {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.region-field-grid > label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
@@ -1656,6 +1743,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
@@ -1669,6 +1757,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
.template-field-head strong {
|
||||
font-size: 0.95rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-field-head .chip {
|
||||
@@ -1782,6 +1871,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.api-region-placeholder-title {
|
||||
@@ -1793,7 +1883,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
|
||||
.api-region-sample-accordion {
|
||||
padding: 0.85rem 1rem;
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--bs-secondary-bg);
|
||||
@@ -1877,7 +1968,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
.region-field-grid--identity,
|
||||
.region-field-grid--position,
|
||||
.region-field-grid--size {
|
||||
.region-field-grid--size,
|
||||
.region-field-grid--animation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderFields = Array.isArray(context.placeholderFields) ? context.placeholderFields : [];
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var sourceOptions = String(context.sourceOptions || '');
|
||||
var itemsPathValue = context.itemsPath !== undefined
|
||||
@@ -234,7 +235,7 @@
|
||||
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||
'<summary>Data</summary>' +
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
@@ -237,9 +237,12 @@
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title.upper()}}, {{title.title()}}, or {{title.lower()}}. Available placeholders:</div>' +
|
||||
'<div class="muted slide-image-file">Use transforms like {{title.upper()}}, {{title.title()}}, or {{title.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio);
|
||||
var disableAudio = context.disableAudio === undefined ? false : Boolean(context.disableAudio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
|
||||
@@ -233,7 +233,7 @@ export function createSlideFormEditorController(options) {
|
||||
promotion: false,
|
||||
statusbar: true,
|
||||
resize: true,
|
||||
plugins: 'lists link code advlist fullscreen table',
|
||||
plugins: 'lists code advlist fullscreen table',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
@@ -244,6 +244,7 @@ export function createSlideFormEditorController(options) {
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
font_family_formats: getFontFamilyFormats(),
|
||||
font_size_input_default_unit: 'px',
|
||||
invalid_elements: 'a',
|
||||
forced_root_block: 'p',
|
||||
force_br_newlines: false,
|
||||
newline_behavior: 'default',
|
||||
|
||||
@@ -200,7 +200,16 @@ export function createSlideFormRegionHelpers(options) {
|
||||
|
||||
function updateRssPlaceholderChips(regionId, feedId) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = buildLimitedPlaceholderChipMarkup(getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
@@ -502,6 +511,11 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
|
||||
placeholderFields: region.region_type === 'api'
|
||||
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
||||
: region.region_type === 'rss'
|
||||
? getRssFieldList(getCurrentRssConfig(region).feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
|
||||
timetableGroups: timetableGroups
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
@@ -533,10 +547,13 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join(''),
|
||||
placeholderChips: region.region_type === 'rss'
|
||||
? getRssFieldList(rssConfig.feed_id).map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
||||
placeholderFields: region.region_type === 'api'
|
||||
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
||||
: region.region_type === 'rss'
|
||||
? getRssFieldList(rssConfig.feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
||||
timetableGroups: timetableGroups
|
||||
};
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
(function (root, factory) {
|
||||
var presets = factory();
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = presets;
|
||||
}
|
||||
|
||||
if (root) {
|
||||
root.templateAnimationPresets = presets;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
|
||||
function option(value, label) {
|
||||
return { value: value, label: label };
|
||||
}
|
||||
|
||||
function formatLabel(value) {
|
||||
return String(value || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, function (letter) {
|
||||
return letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function buildAllValues(groups, extraValues) {
|
||||
var values = [];
|
||||
var seen = Object.create(null);
|
||||
|
||||
function addEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object' || !entry.value || seen[entry.value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[entry.value] = true;
|
||||
values.push(entry.value);
|
||||
}
|
||||
|
||||
Object.keys(groups).forEach(function (groupName) {
|
||||
(groups[groupName] || []).forEach(function (entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
entry.forEach(addEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
addEntry(entry);
|
||||
});
|
||||
});
|
||||
|
||||
(extraValues || []).forEach(function (value) {
|
||||
if (!value || seen[value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[value] = true;
|
||||
values.push(value);
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildLookup(values) {
|
||||
var lookup = Object.create(null);
|
||||
|
||||
values.forEach(function (value) {
|
||||
var key = String(value || '').trim().toLowerCase();
|
||||
if (key && !lookup[key]) {
|
||||
lookup[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function normalizeValue(value, lookup) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return lookup[raw.toLowerCase()] || 'none';
|
||||
}
|
||||
|
||||
function splitAnimateCssValues(values) {
|
||||
var groups = {
|
||||
intro: [],
|
||||
outro: [],
|
||||
attentionSeekers: []
|
||||
};
|
||||
|
||||
(values || []).forEach(function (value) {
|
||||
if (!value || value === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.indexOf('In') !== -1) {
|
||||
groups.intro.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.indexOf('Out') !== -1) {
|
||||
groups.outro.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.attentionSeekers.push(value);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildCuratedBasic(values) {
|
||||
return [option('none', 'None')].concat(values.map(function (value) {
|
||||
return option(value, formatLabel(value));
|
||||
}));
|
||||
}
|
||||
|
||||
var animateCssValues = [
|
||||
'none',
|
||||
'bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat',
|
||||
'backInDown', 'backInLeft', 'backInRight', 'backInUp',
|
||||
'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
|
||||
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp',
|
||||
'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
|
||||
'fadeIn', 'fadeInDown', 'fadeInDownBig', 'fadeInLeft', 'fadeInLeftBig', 'fadeInRight', 'fadeInRightBig', 'fadeInUp', 'fadeInUpBig', 'fadeInTopLeft', 'fadeInTopRight', 'fadeInBottomLeft', 'fadeInBottomRight',
|
||||
'fadeOut', 'fadeOutDown', 'fadeOutDownBig', 'fadeOutLeft', 'fadeOutLeftBig', 'fadeOutRight', 'fadeOutRightBig', 'fadeOutUp', 'fadeOutUpBig', 'fadeOutTopLeft', 'fadeOutTopRight', 'fadeOutBottomLeft', 'fadeOutBottomRight',
|
||||
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY',
|
||||
'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
|
||||
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight',
|
||||
'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
|
||||
'hinge', 'jackInTheBox', 'rollIn', 'rollOut',
|
||||
'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp',
|
||||
'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp',
|
||||
'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
|
||||
'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp'
|
||||
];
|
||||
|
||||
var splitValues = splitAnimateCssValues(animateCssValues);
|
||||
|
||||
var basic = {
|
||||
intro: [
|
||||
option('none', 'None'),
|
||||
option('fadeIn', formatLabel('fadeIn')),
|
||||
option('fadeInDown', formatLabel('fadeInDown')),
|
||||
option('fadeInLeft', formatLabel('fadeInLeft')),
|
||||
option('fadeInRight', formatLabel('fadeInRight')),
|
||||
option('fadeInUp', formatLabel('fadeInUp')),
|
||||
option('zoomIn', formatLabel('zoomIn')),
|
||||
option('zoomInDown', formatLabel('zoomInDown')),
|
||||
option('zoomInLeft', formatLabel('zoomInLeft')),
|
||||
option('zoomInRight', formatLabel('zoomInRight')),
|
||||
option('zoomInUp', formatLabel('zoomInUp')),
|
||||
option('slideInDown', formatLabel('slideInDown')),
|
||||
option('slideInLeft', formatLabel('slideInLeft')),
|
||||
option('slideInRight', formatLabel('slideInRight')),
|
||||
option('slideInUp', formatLabel('slideInUp'))
|
||||
],
|
||||
outro: [
|
||||
option('none', 'None'),
|
||||
option('fadeOut', formatLabel('fadeOut')),
|
||||
option('fadeOutDown', formatLabel('fadeOutDown')),
|
||||
option('fadeOutLeft', formatLabel('fadeOutLeft')),
|
||||
option('fadeOutRight', formatLabel('fadeOutRight')),
|
||||
option('fadeOutUp', formatLabel('fadeOutUp')),
|
||||
option('zoomOut', formatLabel('zoomOut')),
|
||||
option('zoomOutDown', formatLabel('zoomOutDown')),
|
||||
option('zoomOutLeft', formatLabel('zoomOutLeft')),
|
||||
option('zoomOutRight', formatLabel('zoomOutRight')),
|
||||
option('zoomOutUp', formatLabel('zoomOutUp')),
|
||||
option('slideOutDown', formatLabel('slideOutDown')),
|
||||
option('slideOutLeft', formatLabel('slideOutLeft')),
|
||||
option('slideOutRight', formatLabel('slideOutRight')),
|
||||
option('slideOutUp', formatLabel('slideOutUp'))
|
||||
],
|
||||
attentionSeekers: buildCuratedBasic(['flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'])
|
||||
};
|
||||
|
||||
var advanced = {
|
||||
intro: buildCuratedBasic(splitValues.intro),
|
||||
outro: buildCuratedBasic(splitValues.outro),
|
||||
attentionSeekers: buildCuratedBasic(splitValues.attentionSeekers)
|
||||
};
|
||||
|
||||
var lookup = buildLookup(buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }));
|
||||
|
||||
function normalize(value) {
|
||||
return normalizeValue(value, lookup);
|
||||
}
|
||||
|
||||
function isAnimateCssPreset(value) {
|
||||
return animateCssValues.indexOf(normalizeValue(value, buildLookup(animateCssValues))) !== -1;
|
||||
}
|
||||
|
||||
return {
|
||||
basic: basic,
|
||||
advanced: advanced,
|
||||
animateCssValues: animateCssValues,
|
||||
allValues: buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }),
|
||||
normalize: normalize,
|
||||
isAnimateCssPreset: isAnimateCssPreset
|
||||
};
|
||||
});
|
||||
@@ -82,6 +82,174 @@
|
||||
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
||||
}
|
||||
|
||||
function getAnimationConfig(card) {
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
|
||||
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
|
||||
}
|
||||
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
|
||||
}
|
||||
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
|
||||
}
|
||||
|
||||
return animationConfig;
|
||||
}
|
||||
|
||||
function getAnimationPresetValues() {
|
||||
var presets = window.templateAnimationPresets || {};
|
||||
if (Array.isArray(presets.allValues) && presets.allValues.length) {
|
||||
return presets.allValues;
|
||||
}
|
||||
|
||||
return [
|
||||
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
|
||||
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
|
||||
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
|
||||
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
|
||||
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
|
||||
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
|
||||
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
|
||||
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
|
||||
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
|
||||
];
|
||||
}
|
||||
|
||||
function getAnimationPresetMap() {
|
||||
var lookup = {};
|
||||
getAnimationPresetValues().forEach(function (value) {
|
||||
lookup[value] = true;
|
||||
});
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
|
||||
if (!selectInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPreset = normalizePreviewAnimation(preset);
|
||||
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
|
||||
return option && option.value === normalizedPreset;
|
||||
});
|
||||
|
||||
if (hasMatchingOption) {
|
||||
delete selectInput.dataset.animationPresetCustom;
|
||||
selectInput.hidden = false;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = true;
|
||||
}
|
||||
} else {
|
||||
selectInput.dataset.animationPresetCustom = '1';
|
||||
selectInput.hidden = true;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = false;
|
||||
resetButton.textContent = 'Reset to basic';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAnimationConfig(card, animationConfig) {
|
||||
var normalized = normalizeAnimationConfig(animationConfig);
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
|
||||
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
|
||||
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
|
||||
|
||||
if (hiddenAnimationInput) {
|
||||
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
|
||||
}
|
||||
if (introPresetInput) {
|
||||
introPresetInput.value = normalized.intro.preset;
|
||||
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
|
||||
}
|
||||
if (outroPresetInput) {
|
||||
outroPresetInput.value = normalized.outro.preset;
|
||||
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
|
||||
}
|
||||
if (loopPresetInput) {
|
||||
loopPresetInput.value = normalized.loop.preset;
|
||||
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePreviewAnimation(value) {
|
||||
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
|
||||
return window.templateAnimationPresets.normalize(value);
|
||||
}
|
||||
|
||||
var allowed = getAnimationPresetMap();
|
||||
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
return allowed[raw] ? raw : 'none';
|
||||
}
|
||||
|
||||
function normalizeAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
var normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
|
||||
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
|
||||
normalized.easing = String(value.easing || '').trim();
|
||||
normalized.fill_mode = String(value.fill_mode || '').trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null,
|
||||
easing: '',
|
||||
fill_mode: ''
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnimationConfig(value) {
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function formatAnimationConfig(value) {
|
||||
return JSON.stringify(normalizeAnimationConfig(value), null, 2);
|
||||
}
|
||||
|
||||
function syncRegionIdentity(card, value) {
|
||||
var next = String(value || '').trim();
|
||||
card.querySelector('[name="region_name[]"]').value = next;
|
||||
@@ -96,6 +264,7 @@
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
animation_json: getAnimationConfig(card),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
@@ -114,6 +283,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
@@ -171,6 +341,10 @@
|
||||
syncRegionIdentity: syncRegionIdentity,
|
||||
readCard: readCard,
|
||||
writeCard: writeCard,
|
||||
normalizeAnimationConfig: normalizeAnimationConfig,
|
||||
formatAnimationConfig: formatAnimationConfig,
|
||||
getAnimationConfig: getAnimationConfig,
|
||||
writeAnimationConfig: writeAnimationConfig,
|
||||
clampRegion: clampRegion,
|
||||
getOverlayRect: getOverlayRect,
|
||||
toCanvasPoint: toCanvasPoint,
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
: (Array.isArray(templateData) ? templateData : []);
|
||||
var stage = document.getElementById('designer-stage');
|
||||
var overlay = document.getElementById('designer-overlay');
|
||||
var previewCard = document.getElementById('template-preview-card');
|
||||
var previewIntroButton = document.getElementById('template-preview-intro');
|
||||
var previewOutButton = document.getElementById('template-preview-out');
|
||||
var previewContinuousButton = document.getElementById('template-preview-continuous');
|
||||
var regionList = document.getElementById('region-list');
|
||||
var regionSelect = document.getElementById('region-select');
|
||||
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
||||
@@ -36,6 +40,7 @@
|
||||
var addRegionButton = document.getElementById('add-region-button');
|
||||
var regionAddModal = document.getElementById('region-add-modal');
|
||||
var regionAddOptions = document.getElementById('region-add-options');
|
||||
var animationAdvancedModal = document.getElementById('animation-advanced-modal');
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
@@ -43,7 +48,20 @@
|
||||
var regionUsageSet = new Set();
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
var activeAnimationCard = null;
|
||||
var overlayRenderFrame = 0;
|
||||
var previewState = {
|
||||
active: false,
|
||||
mode: '',
|
||||
stopTimer: 0,
|
||||
token: 0,
|
||||
animations: []
|
||||
};
|
||||
var previewButtons = [
|
||||
{ button: previewIntroButton, mode: 'intro' },
|
||||
{ button: previewOutButton, mode: 'out' },
|
||||
{ button: previewContinuousButton, mode: 'continuous' }
|
||||
];
|
||||
|
||||
try {
|
||||
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||
@@ -218,6 +236,7 @@
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
animation_json: getAnimationConfig(card),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
@@ -226,6 +245,582 @@
|
||||
};
|
||||
}
|
||||
|
||||
function hasAnyAnimationCards() {
|
||||
return getCards().some(function (card) {
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
|
||||
return animationConfig && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
var step = animationConfig[stepName] || {};
|
||||
return normalizePreviewAnimation(step.preset) !== 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasPreviewAnimationForMode(mode) {
|
||||
var previewMode = String(mode || '').trim().toLowerCase();
|
||||
if (!previewMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getCards().some(function (card) {
|
||||
var step = getPreviewRegionAnimationStep(readCard(card), previewMode);
|
||||
return normalizePreviewAnimation(step && step.preset) !== 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function getPreviewRects() {
|
||||
if (!overlay) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.prototype.slice.call(overlay.querySelectorAll('.designer-rect'));
|
||||
}
|
||||
|
||||
function clearPreviewAnimations() {
|
||||
previewState.animations.forEach(function (animation) {
|
||||
if (animation && typeof animation.cancel === 'function') {
|
||||
animation.cancel();
|
||||
}
|
||||
});
|
||||
previewState.animations = [];
|
||||
|
||||
getPreviewRects().forEach(function (rect) {
|
||||
rect.classList.remove('animate__animated', 'animate__infinite');
|
||||
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
|
||||
if (window.templateAnimationPresets && Array.isArray(window.templateAnimationPresets.animateCssValues)) {
|
||||
window.templateAnimationPresets.animateCssValues.forEach(function (value) {
|
||||
if (value && value !== 'none') {
|
||||
rect.classList.remove('animate__' + value);
|
||||
}
|
||||
});
|
||||
}
|
||||
rect.style.removeProperty('--animate-duration');
|
||||
rect.style.removeProperty('--animate-delay');
|
||||
rect.style.removeProperty('--animate-repeat');
|
||||
rect.style.opacity = '';
|
||||
rect.style.transform = '';
|
||||
rect.style.filter = '';
|
||||
rect.style.willChange = '';
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreviewControlState() {
|
||||
var activeMode = previewState.active ? previewState.mode : '';
|
||||
|
||||
previewButtons.forEach(function (entry) {
|
||||
if (!entry.button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var available = hasPreviewAnimationForMode(entry.mode);
|
||||
var disabled = !available || (previewState.active && activeMode !== entry.mode);
|
||||
entry.button.disabled = disabled;
|
||||
entry.button.classList.toggle('active', previewState.active && activeMode === entry.mode);
|
||||
entry.button.setAttribute('aria-pressed', previewState.active && activeMode === entry.mode ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePreviewAnimation(value) {
|
||||
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
|
||||
return window.templateAnimationPresets.normalize(value);
|
||||
}
|
||||
|
||||
var allowed = {};
|
||||
var presets = window.templateAnimationPresets || {};
|
||||
var values = Array.isArray(presets.allValues) && presets.allValues.length ? presets.allValues : [
|
||||
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
|
||||
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
|
||||
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
|
||||
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
|
||||
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
|
||||
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
|
||||
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
|
||||
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
|
||||
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
|
||||
];
|
||||
|
||||
values.forEach(function (valueName) {
|
||||
allowed[valueName] = true;
|
||||
});
|
||||
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
return allowed[raw] ? raw : 'none';
|
||||
}
|
||||
|
||||
function isAnimateCssPreset(value) {
|
||||
var presets = window.templateAnimationPresets || {};
|
||||
if (Array.isArray(presets.animateCssValues) && presets.animateCssValues.length) {
|
||||
return presets.animateCssValues.indexOf(normalizePreviewAnimation(value)) !== -1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyAnimateCssPreview(rect, preset, step, mode) {
|
||||
var animateClass = 'animate__' + preset;
|
||||
var durationMs = Number(step && step.duration_ms);
|
||||
var delayMs = Number(step && step.delay_ms);
|
||||
var repeatCount = Number(step && step.iterations);
|
||||
var isAttentionSeeker = preset === 'bounce' || preset === 'flash' || preset === 'pulse' || preset === 'rubberBand' || preset === 'shakeX' || preset === 'shakeY' || preset === 'headShake' || preset === 'swing' || preset === 'tada' || preset === 'wobble' || preset === 'jello' || preset === 'heartBeat';
|
||||
|
||||
rect.classList.remove('animate__animated', 'animate__infinite');
|
||||
rect.style.removeProperty('--animate-duration');
|
||||
rect.style.removeProperty('--animate-delay');
|
||||
rect.style.removeProperty('--animate-repeat');
|
||||
rect.style.setProperty('--animate-duration', Math.max(0, Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 1000) + 'ms');
|
||||
if (Number.isFinite(delayMs) && delayMs > 0) {
|
||||
rect.style.setProperty('--animate-delay', Math.max(0, delayMs) + 'ms');
|
||||
}
|
||||
rect.classList.add('animate__animated', animateClass);
|
||||
if (isAttentionSeeker) {
|
||||
var normalizedRepeatCount = Math.max(1, Number.isFinite(repeatCount) && repeatCount > 0 ? repeatCount : 1);
|
||||
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
|
||||
rect.classList.add('animate__repeat-1');
|
||||
rect.style.setProperty('--animate-repeat', String(normalizedRepeatCount));
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
|
||||
var normalizedMode = String(mode || '').trim().toLowerCase();
|
||||
var durationMs = Number(step && step.duration_ms);
|
||||
var delayMs = Number(step && step.delay_ms);
|
||||
var iterations = Number(step && step.iterations);
|
||||
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
durationMs = animateCssMode ? 1000 : 900;
|
||||
}
|
||||
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
||||
delayMs = 0;
|
||||
}
|
||||
if (!Number.isFinite(iterations) || iterations < 1) {
|
||||
iterations = 1;
|
||||
}
|
||||
|
||||
if (normalizedMode === 'continuous') {
|
||||
return delayMs + (durationMs * iterations);
|
||||
}
|
||||
|
||||
return delayMs + durationMs;
|
||||
}
|
||||
|
||||
function normalizeAnimationStep(value, fallbackPreset) {
|
||||
if (utils.normalizeAnimationStep) {
|
||||
return utils.normalizeAnimationStep(value, fallbackPreset);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
var normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
|
||||
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
|
||||
normalized.fill_mode = String(value.fill_mode || '').trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null,
|
||||
fill_mode: ''
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnimationConfig(value) {
|
||||
if (utils.normalizeAnimationConfig) {
|
||||
return utils.normalizeAnimationConfig(value);
|
||||
}
|
||||
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviewRegionAnimationStep(region, mode) {
|
||||
if (!region) {
|
||||
return normalizeAnimationStep({}, 'none');
|
||||
}
|
||||
|
||||
var animationConfig = normalizeAnimationConfig(region.animation_json !== undefined ? region.animation_json : region);
|
||||
|
||||
if (mode === 'intro') {
|
||||
return animationConfig.intro;
|
||||
}
|
||||
|
||||
if (mode === 'out') {
|
||||
return animationConfig.outro;
|
||||
}
|
||||
|
||||
return animationConfig.loop;
|
||||
}
|
||||
|
||||
function getAnimationConfig(card) {
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
|
||||
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
|
||||
}
|
||||
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
|
||||
}
|
||||
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
|
||||
}
|
||||
|
||||
return animationConfig;
|
||||
}
|
||||
|
||||
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
|
||||
if (!selectInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPreset = normalizePreviewAnimation(preset);
|
||||
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
|
||||
return option && option.value === normalizedPreset;
|
||||
});
|
||||
|
||||
if (hasMatchingOption) {
|
||||
delete selectInput.dataset.animationPresetCustom;
|
||||
selectInput.hidden = false;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = true;
|
||||
}
|
||||
} else {
|
||||
selectInput.dataset.animationPresetCustom = '1';
|
||||
selectInput.hidden = true;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = false;
|
||||
resetButton.textContent = 'Reset to basic';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAnimationConfig(card, animationConfig) {
|
||||
var normalized = normalizeAnimationConfig(animationConfig);
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
|
||||
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
|
||||
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
|
||||
|
||||
if (hiddenAnimationInput) {
|
||||
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
|
||||
}
|
||||
if (introPresetInput) {
|
||||
introPresetInput.value = normalized.intro.preset;
|
||||
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
|
||||
}
|
||||
if (outroPresetInput) {
|
||||
outroPresetInput.value = normalized.outro.preset;
|
||||
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
|
||||
}
|
||||
if (loopPresetInput) {
|
||||
loopPresetInput.value = normalized.loop.preset;
|
||||
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
|
||||
}
|
||||
|
||||
updatePreviewCardVisibility();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function setAnimationPreset(card, stepName, preset) {
|
||||
var config = getAnimationConfig(card);
|
||||
if (config[stepName]) {
|
||||
config[stepName].preset = normalizePreviewAnimation(preset);
|
||||
config[stepName].duration_ms = null;
|
||||
config[stepName].delay_ms = null;
|
||||
config[stepName].iterations = null;
|
||||
config[stepName].easing = '';
|
||||
config[stepName].fill_mode = '';
|
||||
writeAnimationConfig(card, config);
|
||||
if (activeAnimationCard === card) {
|
||||
populateAnimationModal(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnimationModalField(stepName, fieldName) {
|
||||
if (!animationAdvancedModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
|
||||
}
|
||||
|
||||
function populateAnimationModal(card) {
|
||||
if (!animationAdvancedModal || !card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var title = animationAdvancedModal.querySelector('[data-animation-modal-title]');
|
||||
var label = card.querySelector('[data-region-title]');
|
||||
var config = getAnimationConfig(card);
|
||||
|
||||
if (title) {
|
||||
title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
|
||||
}
|
||||
|
||||
['intro', 'outro', 'loop'].forEach(function (stepName) {
|
||||
var step = config[stepName] || normalizeAnimationStep({}, 'none');
|
||||
var presetInput = getAnimationModalField(stepName, 'preset');
|
||||
var durationInput = getAnimationModalField(stepName, 'duration_ms');
|
||||
var delayInput = getAnimationModalField(stepName, 'delay_ms');
|
||||
|
||||
if (presetInput) { presetInput.value = step.preset; }
|
||||
if (durationInput) { durationInput.value = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(step.duration_ms); }
|
||||
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
|
||||
if (stepName === 'loop') {
|
||||
var iterationsInput = getAnimationModalField(stepName, 'iterations');
|
||||
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(step.iterations); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readNumberField(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
var next = Number(raw);
|
||||
return Number.isFinite(next) ? Math.round(next) : null;
|
||||
}
|
||||
|
||||
function readAnimationModalConfig() {
|
||||
if (!animationAdvancedModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
|
||||
|
||||
['intro', 'outro', 'loop'].forEach(function (stepName) {
|
||||
var presetInput = getAnimationModalField(stepName, 'preset');
|
||||
var durationInput = getAnimationModalField(stepName, 'duration_ms');
|
||||
var delayInput = getAnimationModalField(stepName, 'delay_ms');
|
||||
|
||||
var nextStep = Object.assign({}, config[stepName] || {});
|
||||
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
|
||||
nextStep.duration_ms = readNumberField(durationInput ? durationInput.value : '') || 1000;
|
||||
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
|
||||
if (stepName === 'loop') {
|
||||
var iterationsInput = getAnimationModalField(stepName, 'iterations');
|
||||
nextStep.iterations = readNumberField(iterationsInput ? iterationsInput.value : '') || 1;
|
||||
}
|
||||
|
||||
config[stepName] = normalizeAnimationStep(nextStep, 'none');
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function saveAnimationModal() {
|
||||
if (!activeAnimationCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config = readAnimationModalConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeAnimationConfig(activeAnimationCard, config);
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function setPreviewControlState(active) {
|
||||
updatePreviewControlState();
|
||||
}
|
||||
|
||||
function updatePreviewCardVisibility() {
|
||||
if (!previewCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewCard.hidden = !hasAnyAnimationCards();
|
||||
}
|
||||
|
||||
function setTemplateEditorLocked(locked) {
|
||||
if (templateForm) {
|
||||
templateForm.classList.toggle('template-designer-form--previewing', locked);
|
||||
}
|
||||
if (stage) {
|
||||
stage.classList.toggle('is-previewing', locked);
|
||||
}
|
||||
if (previewCard) {
|
||||
previewCard.classList.toggle('is-previewing', locked);
|
||||
}
|
||||
if (overlay) {
|
||||
overlay.style.pointerEvents = locked ? 'none' : '';
|
||||
}
|
||||
|
||||
var controls = templateForm ? templateForm.querySelectorAll('input, select, textarea, button') : [];
|
||||
Array.prototype.forEach.call(controls, function (control) {
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
if (control.matches && control.matches('[data-preview-control]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
if (!control.hasAttribute('data-preview-disabled')) {
|
||||
control.setAttribute('data-preview-disabled', control.disabled ? '1' : '0');
|
||||
}
|
||||
control.disabled = true;
|
||||
} else if (control.hasAttribute('data-preview-disabled')) {
|
||||
control.disabled = control.getAttribute('data-preview-disabled') === '1';
|
||||
control.removeAttribute('data-preview-disabled');
|
||||
}
|
||||
});
|
||||
|
||||
setPreviewControlState(locked);
|
||||
}
|
||||
|
||||
function stopTemplatePreview(skipRender) {
|
||||
if (previewState.stopTimer) {
|
||||
window.clearTimeout(previewState.stopTimer);
|
||||
previewState.stopTimer = 0;
|
||||
}
|
||||
|
||||
previewState.token += 1;
|
||||
previewState.active = false;
|
||||
previewState.mode = '';
|
||||
clearPreviewAnimations();
|
||||
setTemplateEditorLocked(false);
|
||||
|
||||
if (!skipRender) {
|
||||
renderOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreviewStop(delayMs, token) {
|
||||
if (previewState.stopTimer) {
|
||||
window.clearTimeout(previewState.stopTimer);
|
||||
}
|
||||
|
||||
previewState.stopTimer = window.setTimeout(function () {
|
||||
if (token !== previewState.token) {
|
||||
return;
|
||||
}
|
||||
stopTemplatePreview(false);
|
||||
}, Math.max(0, Number(delayMs || 0)));
|
||||
}
|
||||
|
||||
function animatePreviewRects(mode, regions) {
|
||||
var rects = getPreviewRects();
|
||||
var token = previewState.token;
|
||||
var stagger = 90;
|
||||
var duration = 900;
|
||||
var overlayRect = getOverlayRect();
|
||||
var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02));
|
||||
var longestTimingMs = 0;
|
||||
|
||||
previewState.animations = [];
|
||||
|
||||
rects.forEach(function (rect, index) {
|
||||
var region = regions && regions[index] ? regions[index] : null;
|
||||
var step = getPreviewRegionAnimationStep(region, mode);
|
||||
var preset = normalizePreviewAnimation(step && step.preset);
|
||||
var delay = index * stagger;
|
||||
var animation = null;
|
||||
var animateCssMode = isAnimateCssPreset(preset);
|
||||
var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode);
|
||||
|
||||
rect.style.willChange = 'transform, opacity, filter';
|
||||
|
||||
if (timingMs > longestTimingMs) {
|
||||
longestTimingMs = timingMs;
|
||||
}
|
||||
|
||||
if (preset === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (animateCssMode) {
|
||||
applyAnimateCssPreview(rect, preset, step, mode);
|
||||
return;
|
||||
}
|
||||
if (mode === 'intro') {
|
||||
animation = rect.animate([
|
||||
{ opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' },
|
||||
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }
|
||||
], {
|
||||
duration: duration,
|
||||
delay: delay,
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
fill: 'both'
|
||||
});
|
||||
} else if (mode === 'out') {
|
||||
animation = rect.animate([
|
||||
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' },
|
||||
{ opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }
|
||||
], {
|
||||
duration: duration,
|
||||
delay: delay,
|
||||
easing: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
fill: 'both'
|
||||
});
|
||||
}
|
||||
|
||||
if (animation) {
|
||||
previewState.animations.push(animation);
|
||||
}
|
||||
});
|
||||
|
||||
schedulePreviewStop(longestTimingMs + 140, token);
|
||||
}
|
||||
|
||||
function startTemplatePreview(mode) {
|
||||
var previewMode = String(mode || '').trim().toLowerCase();
|
||||
if (!previewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
var previewRegions = getCards().map(readCard);
|
||||
stopTemplatePreview(true);
|
||||
previewState.active = true;
|
||||
previewState.mode = previewMode;
|
||||
previewState.token += 1;
|
||||
setTemplateEditorLocked(true);
|
||||
|
||||
if (!getPreviewRects().length) {
|
||||
schedulePreviewStop(120, previewState.token);
|
||||
return;
|
||||
}
|
||||
animatePreviewRects(previewMode, previewRegions);
|
||||
}
|
||||
|
||||
function writeCard(card, values) {
|
||||
if (utils.writeCard) {
|
||||
utils.writeCard(card, values);
|
||||
@@ -240,6 +835,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
@@ -401,6 +997,7 @@
|
||||
if (regionLockRatioInput) {
|
||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
|
||||
}
|
||||
writeAnimationConfig(card, region.animation_json);
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
@@ -452,6 +1049,9 @@
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
var animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
|
||||
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
|
||||
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
@@ -459,6 +1059,28 @@
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
Array.prototype.forEach.call(animationPresetInputs, function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
|
||||
requestOverlayRender();
|
||||
});
|
||||
});
|
||||
Array.prototype.forEach.call(animationResetButtons, function (button) {
|
||||
button.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
|
||||
requestOverlayRender();
|
||||
});
|
||||
});
|
||||
if (animationAdvancedButton && animationAdvancedModal) {
|
||||
animationAdvancedButton.addEventListener('click', function () {
|
||||
activeAnimationCard = card;
|
||||
populateAnimationModal(card);
|
||||
if (window.pulseModal) {
|
||||
window.pulseModal.show(animationAdvancedModal);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (lockRatioInput) {
|
||||
lockRatioInput.addEventListener('input', function () {
|
||||
updateRegionLockBadge(card);
|
||||
@@ -536,28 +1158,49 @@
|
||||
regionSelect.value = String(selectedIndex);
|
||||
updateCanvasSizeLock();
|
||||
validateRegionNames();
|
||||
updatePreviewCardVisibility();
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cards = getCards();
|
||||
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
||||
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
||||
var regions = cards.map(readCard);
|
||||
var canvasSize = getCanvasSize();
|
||||
var overlayRect = getOverlayRect();
|
||||
var overlayScale = overlayRect.width / Math.max(1, canvasSize.width);
|
||||
overlay.style.setProperty('--designer-overlay-scale', String(overlayScale));
|
||||
regionsJsonInput.value = JSON.stringify(regions);
|
||||
overlay.innerHTML = regions.map(function (region, index) {
|
||||
var box = canvasRectToPixels(region);
|
||||
var renderedBox = {
|
||||
left: Math.round(box.left),
|
||||
top: Math.round(box.top),
|
||||
width: Math.round(box.width),
|
||||
height: Math.round(box.height)
|
||||
};
|
||||
var selected = index === selectedNow ? ' selected' : '';
|
||||
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
|
||||
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
|
||||
}).join('');
|
||||
if (draft) {
|
||||
var rect = getOverlayRect();
|
||||
var size = getCanvasSize();
|
||||
var rect = overlayRect;
|
||||
var size = canvasSize;
|
||||
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
|
||||
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
|
||||
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + Math.round((draftBox.x / size.width) * rect.width) + 'px;top:' + Math.round((draftBox.y / size.height) * rect.height) + 'px;width:' + Math.round((draftBox.width / size.width) * rect.width) + 'px;height:' + Math.round((draftBox.height / size.height) * rect.height) + 'px;"></div>';
|
||||
}
|
||||
|
||||
updatePreviewControlState();
|
||||
}
|
||||
|
||||
function requestOverlayRender() {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlayRenderFrame) {
|
||||
return;
|
||||
}
|
||||
@@ -575,6 +1218,10 @@
|
||||
}
|
||||
|
||||
function setSelected(index) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cards = getCards();
|
||||
if (!cards.length) {
|
||||
selectedIndex = -1;
|
||||
@@ -588,6 +1235,10 @@
|
||||
}
|
||||
|
||||
function addRegion(region) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hint = regionList.querySelector('.muted');
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
@@ -613,6 +1264,7 @@
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
animation_json: normalizeAnimationConfig('{}'),
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: size.width,
|
||||
@@ -639,6 +1291,10 @@
|
||||
}
|
||||
|
||||
function startDraw(event) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var start = toCanvasPoint(event);
|
||||
draft = { start: start, end: start };
|
||||
renderOverlay();
|
||||
@@ -664,6 +1320,10 @@
|
||||
}
|
||||
|
||||
function startMove(index, event) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
function moveHandler(moveEvent) {
|
||||
@@ -687,6 +1347,10 @@
|
||||
}
|
||||
|
||||
function resizeFromHandle(index, dir, event) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||
@@ -824,6 +1488,31 @@
|
||||
backgroundEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
if (animationAdvancedModal) {
|
||||
animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
|
||||
activeAnimationCard = null;
|
||||
});
|
||||
var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
|
||||
var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
|
||||
if (animationModalApplyButton) {
|
||||
animationModalApplyButton.addEventListener('click', function () {
|
||||
saveAnimationModal();
|
||||
if (window.pulseModal) {
|
||||
window.pulseModal.hide(animationAdvancedModal);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (animationModalResetButton) {
|
||||
animationModalResetButton.addEventListener('click', function () {
|
||||
if (!activeAnimationCard) {
|
||||
return;
|
||||
}
|
||||
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
|
||||
populateAnimationModal(activeAnimationCard);
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (backgroundColorInput) {
|
||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
||||
}
|
||||
@@ -853,6 +1542,11 @@
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
if (previewState.active) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateRegionNames()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -868,6 +1562,10 @@
|
||||
});
|
||||
|
||||
templateForm.addEventListener('submit', function () {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateRegionNames()) {
|
||||
return;
|
||||
}
|
||||
@@ -882,6 +1580,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (previewIntroButton) {
|
||||
previewIntroButton.addEventListener('click', function () {
|
||||
if (previewState.active && previewState.mode === 'intro') {
|
||||
stopTemplatePreview(false);
|
||||
return;
|
||||
}
|
||||
startTemplatePreview('intro');
|
||||
});
|
||||
}
|
||||
|
||||
if (previewOutButton) {
|
||||
previewOutButton.addEventListener('click', function () {
|
||||
if (previewState.active && previewState.mode === 'out') {
|
||||
stopTemplatePreview(false);
|
||||
return;
|
||||
}
|
||||
startTemplatePreview('out');
|
||||
});
|
||||
}
|
||||
|
||||
if (previewContinuousButton) {
|
||||
previewContinuousButton.addEventListener('click', function () {
|
||||
if (previewState.active && previewState.mode === 'continuous') {
|
||||
stopTemplatePreview(false);
|
||||
return;
|
||||
}
|
||||
startTemplatePreview('continuous');
|
||||
});
|
||||
}
|
||||
|
||||
renderRegionList(existingRegions);
|
||||
syncCanvasSizeSelection();
|
||||
updateStageBackgroundColor();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -501,8 +501,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -563,8 +563,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared template form view-model builder.
|
||||
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
const animationPresets = require('../../../public/js/templates/animation-presets');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
@@ -61,7 +62,8 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
||||
cancelUrl: '/templates',
|
||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||
canvasSizes: canvasSizes || [],
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
animationPresets: animationPresets,
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="{{assetHref this}}" />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{{#> modal-shell modalId="animation-advanced-modal" modalLabelId="animation-advanced-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title fs-5" id="animation-advanced-modal-label" data-animation-modal-title>Advanced animation settings</h2>
|
||||
<p class="text-body-secondary mb-0">Fine-tune the selected region animation JSON without editing raw data.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body d-grid gap-4">
|
||||
<div class="alert alert-secondary mb-0">Changes here are saved into the region animation JSON and stay in sync with the preset dropdowns.</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-secondary-subtle">
|
||||
<div class="card-header bg-transparent">
|
||||
<strong class="text-capitalize">Intro</strong>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-3 align-content-start">
|
||||
<label class="d-grid gap-1">
|
||||
Preset
|
||||
<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">
|
||||
{{#each animationPresets.advanced.intro}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Duration ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="1000" />
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Delay ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-secondary-subtle">
|
||||
<div class="card-header bg-transparent">
|
||||
<strong class="text-capitalize">Outro</strong>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-3 align-content-start">
|
||||
<label class="d-grid gap-1">
|
||||
Preset
|
||||
<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">
|
||||
{{#each animationPresets.advanced.outro}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Duration ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="1000" />
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Delay ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-secondary-subtle">
|
||||
<div class="card-header bg-transparent">
|
||||
<strong class="text-capitalize">Attention seekers</strong>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-3 align-content-start">
|
||||
<label class="d-grid gap-1">
|
||||
Preset
|
||||
<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">
|
||||
{{#each animationPresets.advanced.attentionSeekers}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Duration ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="1000" />
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Delay ms
|
||||
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" />
|
||||
</label>
|
||||
<label class="d-grid gap-1">
|
||||
Repeat
|
||||
<input class="form-control" type="number" min="1" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-animation-modal-apply>Apply changes</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
@@ -31,10 +31,17 @@
|
||||
</div>
|
||||
<div class="template-designer-sidebar">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-body d-grid">
|
||||
<div class="card-body d-grid gap-3 pb-0">
|
||||
<div class="btn-group" role="group" aria-label="Template actions">
|
||||
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
<div class="d-grid gap-2 template-preview-card" id="template-preview-card" hidden>
|
||||
<div class="btn-group flex-wrap gap-0" role="group" aria-label="Template animation preview controls">
|
||||
<button type="button" class="btn btn-outline-secondary" id="template-preview-intro" data-preview-control data-preview-action="intro" aria-pressed="false">Intro</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="template-preview-out" data-preview-control data-preview-action="out" aria-pressed="false">Outro</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="template-preview-continuous" data-preview-control data-preview-action="continuous" aria-pressed="false">Attention</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
|
||||
@@ -131,7 +138,7 @@
|
||||
|
||||
<template id="region-card-template">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
||||
<div class="card-header template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div>
|
||||
<div class="card-body p-3 d-grid gap-3 pb-0">
|
||||
<div class="region-field-grid region-field-grid--identity">
|
||||
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
|
||||
@@ -148,10 +155,57 @@
|
||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
||||
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
||||
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
||||
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
|
||||
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
|
||||
</div>
|
||||
<div class="region-field-grid region-field-grid--animation">
|
||||
<div class="d-grid gap-1">
|
||||
<label>
|
||||
Intro
|
||||
<select class="form-select" data-animation-preset="intro">
|
||||
{{#each animationPresets.basic.intro}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
|
||||
</div>
|
||||
<div class="d-grid gap-1">
|
||||
<label>
|
||||
Outro
|
||||
<select class="form-select" data-animation-preset="outro">
|
||||
{{#each animationPresets.basic.outro}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="region-field-grid region-field-grid--animation">
|
||||
<div class="d-grid gap-1">
|
||||
<label>
|
||||
Attention seekers
|
||||
<select class="form-select" data-animation-preset="loop">
|
||||
{{#each animationPresets.basic.attentionSeekers}}
|
||||
<option value="{{value}}">{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
|
||||
</div>
|
||||
<div class="d-grid gap-1">
|
||||
<label>
|
||||
Advanced
|
||||
<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button>Advanced</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{> signage/templates/animation-advanced-modal}}
|
||||
|
||||
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const {
|
||||
collectLiveConnections,
|
||||
isClientNameAvailable,
|
||||
normalizeClientName,
|
||||
normalizeDeviceId,
|
||||
withClientNameReservation
|
||||
} = require('../src/data/client-name-check');
|
||||
|
||||
test('normalize helpers trim names and sanitize device ids', () => {
|
||||
assert.equal(normalizeClientName(' Screen A '), 'Screen A');
|
||||
assert.equal(normalizeClientName(''), '');
|
||||
assert.equal(normalizeDeviceId(' device-01 /abc!? '), 'device-01abc');
|
||||
assert.equal(normalizeDeviceId('x'.repeat(200)).length, 128);
|
||||
});
|
||||
|
||||
test('collectLiveConnections returns an array safely', () => {
|
||||
assert.deepEqual(collectLiveConnections(null), []);
|
||||
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
|
||||
});
|
||||
|
||||
test('isClientNameAvailable rejects matching db rows and live connections', async () => {
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
assert.match(sql, /FROM d_onboarding_devices/);
|
||||
assert.deepEqual(params, ['Screen A', 'device-01']);
|
||||
return [[{ device_id: 'device-99' }]];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
|
||||
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
|
||||
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
|
||||
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
|
||||
});
|
||||
|
||||
test('withClientNameReservation acquires and releases locks around the handler', async () => {
|
||||
const calls = [];
|
||||
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
|
||||
const connection = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('GET_LOCK')) {
|
||||
return [[{ lock_result: 1 }]];
|
||||
}
|
||||
return [[{ released: 1 }]];
|
||||
},
|
||||
release() {
|
||||
calls.push({ sql: 'RELEASE_CONNECTION' });
|
||||
}
|
||||
};
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
|
||||
const result = await withClientNameReservation(pool, ' Screen A ', async () => 'ok');
|
||||
|
||||
assert.equal(result, 'ok');
|
||||
assert.deepEqual(calls, [
|
||||
{ sql: 'SELECT GET_LOCK(?, 5) AS lock_result', params: [lockName] },
|
||||
{ sql: 'SELECT RELEASE_LOCK(?)', params: [lockName] },
|
||||
{ sql: 'RELEASE_CONNECTION' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('withClientNameReservation rejects busy names', async () => {
|
||||
const connection = {
|
||||
async query(sql) {
|
||||
if (sql.includes('GET_LOCK')) {
|
||||
return [[{ lock_result: 0 }]];
|
||||
}
|
||||
throw new Error('unexpected query');
|
||||
},
|
||||
release() {}
|
||||
};
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => withClientNameReservation(pool, 'Screen A', async () => 'ok'),
|
||||
(error) => error && error.statusCode === 409 && error.message === 'Client name is busy. Please try again.'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildSearchFilter,
|
||||
buildSortOrderClause,
|
||||
fetchPagedRows,
|
||||
findTopLevelOrderByIndex,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
parseJsonSafe,
|
||||
readFormArray
|
||||
} = require('../src/data/utils');
|
||||
|
||||
test('parseJsonSafe returns null for invalid JSON and preserves objects', () => {
|
||||
assert.equal(parseJsonSafe(''), null);
|
||||
assert.equal(parseJsonSafe('not-json'), null);
|
||||
assert.deepEqual(parseJsonSafe('{"value":true}'), { value: true });
|
||||
const value = { nested: ['a'] };
|
||||
assert.equal(parseJsonSafe(value), value);
|
||||
});
|
||||
|
||||
test('readFormArray always returns an array shape', () => {
|
||||
assert.deepEqual(readFormArray({}, 'roles'), []);
|
||||
assert.deepEqual(readFormArray({ roles: 'admin' }, 'roles'), ['admin']);
|
||||
assert.deepEqual(readFormArray({ roles: ['admin', 'editor'] }, 'roles'), ['admin', 'editor']);
|
||||
});
|
||||
|
||||
test('normalize helpers clamp pagination and sort direction', () => {
|
||||
assert.equal(normalizePageNumber('0'), 1);
|
||||
assert.equal(normalizePageNumber('3.9'), 3);
|
||||
assert.equal(normalizePageNumber('abc'), 1);
|
||||
assert.equal(normalizeSortDirection('DESC'), 'desc');
|
||||
assert.equal(normalizeSortDirection('anything else'), 'asc');
|
||||
});
|
||||
|
||||
test('buildSortOrderClause applies multiple columns and ignores unknown keys', () => {
|
||||
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'name', 'desc'), {
|
||||
clause: ' ORDER BY title DESC',
|
||||
sortKey: 'name',
|
||||
sortDirection: 'desc'
|
||||
});
|
||||
assert.deepEqual(buildSortOrderClause({ name: ['title', 'id'] }, 'name', 'asc'), {
|
||||
clause: ' ORDER BY title ASC, id ASC',
|
||||
sortKey: 'name',
|
||||
sortDirection: 'asc'
|
||||
});
|
||||
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'missing', 'desc'), {
|
||||
clause: '',
|
||||
sortKey: 'missing',
|
||||
sortDirection: 'desc'
|
||||
});
|
||||
});
|
||||
|
||||
test('buildSearchFilter generates escaped LIKE clauses', () => {
|
||||
assert.deepEqual(buildSearchFilter(['name', 'description'], '100%_ready'), {
|
||||
clause: " WHERE (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\' OR LOWER(COALESCE(CAST(description AS CHAR), '')) LIKE ? ESCAPE '\\\\')",
|
||||
params: ['%100\\%\\_ready%', '%100\\%\\_ready%']
|
||||
});
|
||||
assert.deepEqual(buildSearchFilter([], 'anything'), { clause: '', params: [] });
|
||||
});
|
||||
|
||||
test('findTopLevelOrderByIndex skips nested subqueries', () => {
|
||||
const sql = 'SELECT * FROM (SELECT * FROM items ORDER BY created_at DESC) AS nested ORDER BY id ASC';
|
||||
assert.equal(findTopLevelOrderByIndex(sql), sql.lastIndexOf('ORDER BY id ASC'));
|
||||
});
|
||||
|
||||
test('fetchPagedRows combines search, ordering, and pagination parameters', async () => {
|
||||
const queries = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
queries.push({ sql, params });
|
||||
if (sql.startsWith('SELECT COUNT(*)')) {
|
||||
return [[{ count: 3 }]];
|
||||
}
|
||||
return [[{ id: 1 }]];
|
||||
}
|
||||
};
|
||||
|
||||
const result = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name FROM items WHERE active = 1 ORDER BY name ASC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM items WHERE active = 1',
|
||||
params: ['active'],
|
||||
searchColumns: ['name'],
|
||||
searchTerm: 'alpha',
|
||||
sortColumns: { name: 'name' },
|
||||
sortKey: 'name',
|
||||
sortDirection: 'desc',
|
||||
pageSize: 2,
|
||||
page: 2
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
rows: [{ id: 1 }],
|
||||
totalItems: 3,
|
||||
totalPages: 2,
|
||||
currentPage: 2,
|
||||
pageSize: 2
|
||||
});
|
||||
assert.equal(queries.length, 2);
|
||||
assert.equal(queries[0].sql, "SELECT COUNT(*) AS count FROM (SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\')) AS filtered_rows");
|
||||
assert.deepEqual(queries[0].params, ['active', '%alpha%']);
|
||||
assert.equal(queries[1].sql, "SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\') ORDER BY name DESC LIMIT ? OFFSET ?");
|
||||
assert.deepEqual(queries[1].params, ['active', '%alpha%', 2, 2]);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
const { createPlayerPlaylistService } = require('../src/player/playlist');
|
||||
|
||||
function createFsStub() {
|
||||
const original = {
|
||||
readFile: fs.promises.readFile,
|
||||
writeFile: fs.promises.writeFile,
|
||||
mkdir: fs.promises.mkdir
|
||||
};
|
||||
const calls = [];
|
||||
|
||||
fs.promises.readFile = async function (filePath) {
|
||||
calls.push({ method: 'readFile', filePath });
|
||||
if (filePath.endsWith('fallback.json')) {
|
||||
return JSON.stringify({ fromSnapshot: true, screen: { slug: 'test2' } });
|
||||
}
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
};
|
||||
|
||||
fs.promises.writeFile = async function (filePath, content) {
|
||||
calls.push({ method: 'writeFile', filePath, content });
|
||||
};
|
||||
|
||||
fs.promises.mkdir = async function (dirPath, options) {
|
||||
calls.push({ method: 'mkdir', dirPath, options });
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
fs.promises.readFile = original.readFile;
|
||||
fs.promises.writeFile = original.writeFile;
|
||||
fs.promises.mkdir = original.mkdir;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('buildScreenPlaylist assembles slides, templates, and derived values', async () => {
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('FROM d_screens')) {
|
||||
assert.deepEqual(params, ['test2']);
|
||||
return [[{ id: 7, name: 'Screen 7', slug: 'test2', playlist_id: 22, modified_at: '2026-08-03T00:00:00.000Z' }]];
|
||||
}
|
||||
if (sql.includes('FROM c_playlists')) {
|
||||
assert.deepEqual(params, [22]);
|
||||
return [[{ id: 22, name: 'Playlist 22', fade_between_slides: 1, skip_unavailable_rtmp: 0, modified_at: '2026-08-02T00:00:00.000Z' }]];
|
||||
}
|
||||
if (sql.includes('FROM c_playlist_slides ps')) {
|
||||
return [[{
|
||||
id: 101,
|
||||
title: 'Intro',
|
||||
template_id: 33,
|
||||
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"}}',
|
||||
modified_at: '2026-08-03T00:00:01.000Z',
|
||||
position: 1,
|
||||
duration_seconds: 9,
|
||||
use_video_duration: 1,
|
||||
disable_audio: null,
|
||||
template_name: 'Template 33',
|
||||
canvas_size_name: 'HD',
|
||||
canvas_size_width: 1920,
|
||||
canvas_size_height: 1080,
|
||||
canvas_width: 1920,
|
||||
canvas_height: 1080
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
|
||||
return [[
|
||||
{ id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' },
|
||||
{ id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }
|
||||
]];
|
||||
}
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
assert.deepEqual(params, [[33]]);
|
||||
return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
|
||||
}
|
||||
if (sql.includes('FROM c_template_regions')) {
|
||||
assert.deepEqual(params, [[33]]);
|
||||
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
|
||||
}
|
||||
throw new Error(`unexpected query: ${sql}`);
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
parseJsonSafe(value) {
|
||||
return JSON.parse(value);
|
||||
},
|
||||
fetchRssFeedsData: async () => ({ rssFeeds: [{ id: 1, title: 'Feed 1' }] }),
|
||||
fetchRssFeedItemsByFeedId: async () => ([{ title: 'Item 1', link: 'https://example.com' }]),
|
||||
normalizeRssFeedItem(item) {
|
||||
return Object.assign({}, item, { normalized: true });
|
||||
},
|
||||
fetchApiSourcesData: async () => ({ apiSources: [{ id: 2, name: 'Source 2', last_response_json: '{"ok":true}' }] }),
|
||||
fetchTimetablesData: async () => ({ timetableGroups: [{ id: 3, name: 'Group 3' }] })
|
||||
};
|
||||
const fsStub = createFsStub();
|
||||
|
||||
try {
|
||||
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
|
||||
const payload = await service.buildScreenPlaylist('test2');
|
||||
|
||||
assert.equal(payload.screen.slug, 'test2');
|
||||
assert.equal(payload.playlist.name, 'Playlist 22');
|
||||
assert.equal(payload.slides[0].duration_seconds, 12.346);
|
||||
assert.equal(payload.slides[0].disable_audio, true);
|
||||
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
||||
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
||||
assert.equal(payload.slides[0].scheduleRules.length, 2);
|
||||
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
|
||||
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
|
||||
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
|
||||
assert.deepEqual(payload.timetableGroups, [{ id: 3, name: 'Group 3' }]);
|
||||
assert.match(payload.revision, /^[a-f0-9]{40}$/);
|
||||
assert.ok(fsStub.calls.some((call) => call.method === 'writeFile'));
|
||||
} finally {
|
||||
fsStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildScreenPlaylist falls back to a snapshot when queries fail', async () => {
|
||||
const pool = {
|
||||
async query() {
|
||||
throw new Error('database unavailable');
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
parseJsonSafe() {
|
||||
throw new Error('not expected');
|
||||
}
|
||||
};
|
||||
const fsStub = createFsStub();
|
||||
|
||||
try {
|
||||
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
|
||||
const payload = await service.buildScreenPlaylist('fallback');
|
||||
|
||||
assert.deepEqual(payload, { fromSnapshot: true, screen: { slug: 'test2' } });
|
||||
assert.ok(fsStub.calls.some((call) => call.method === 'readFile'));
|
||||
} finally {
|
||||
fsStub.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const {
|
||||
mediaKind,
|
||||
normalizeSlide,
|
||||
renderEditorJsContent,
|
||||
sanitizeRichText
|
||||
} = require('../src/player/render-helpers');
|
||||
|
||||
test('mediaKind classifies player media by extension', () => {
|
||||
assert.equal(mediaKind('poster.PNG'), 'image');
|
||||
assert.equal(mediaKind('intro.mp4'), 'video');
|
||||
assert.equal(mediaKind('manual.pdf'), 'pdf');
|
||||
assert.equal(mediaKind('notes.txt'), 'file');
|
||||
});
|
||||
|
||||
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
|
||||
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
|
||||
|
||||
assert.equal(
|
||||
sanitizeRichText(html),
|
||||
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSlide normalizes nested content without mutating the source', () => {
|
||||
const slide = {
|
||||
id: 12,
|
||||
content: {
|
||||
hero: {
|
||||
value: '{"headline":"Hello"}',
|
||||
font_family: ' Open Sans! ',
|
||||
font_size: '42',
|
||||
font_color: 'not-a-color',
|
||||
type: 'text'
|
||||
},
|
||||
footer: 'plain text'
|
||||
}
|
||||
};
|
||||
|
||||
const normalized = normalizeSlide(slide);
|
||||
|
||||
assert.notEqual(normalized.content, slide.content);
|
||||
assert.deepEqual(normalized.content.hero, {
|
||||
value: { headline: 'Hello' },
|
||||
font_family: ' Open Sans! ',
|
||||
font_size: '42',
|
||||
font_color: 'not-a-color',
|
||||
type: 'text'
|
||||
});
|
||||
assert.deepEqual(normalized.content.footer, {
|
||||
type: 'text',
|
||||
value: 'plain text'
|
||||
});
|
||||
assert.deepEqual(slide.content.hero, {
|
||||
value: '{"headline":"Hello"}',
|
||||
font_family: ' Open Sans! ',
|
||||
font_size: '42',
|
||||
font_color: 'not-a-color',
|
||||
type: 'text'
|
||||
});
|
||||
});
|
||||
|
||||
test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => {
|
||||
const editorJson = {
|
||||
blocks: [
|
||||
{ type: 'header', data: { level: 2, text: '<strong>Title</strong><script>bad()</script>' } },
|
||||
{ type: 'paragraph', data: { text: '<a href="javascript:alert(1)">bad</a><em>ok</em>' } },
|
||||
{ type: 'list', data: { style: 'ordered', items: ['One', { text: '<span>Two</span>' }] } }
|
||||
]
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
renderEditorJsContent(editorJson),
|
||||
'<h2><strong>Title</strong></h2><p><a>bad</a><em>ok</em></p><ol style="list-style-type:decimal;padding-left:1.4em;"><li>One</li><li><span>Two</span></li></ol>'
|
||||
);
|
||||
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { registerPlayerRoutes } = require('../src/player/routes');
|
||||
|
||||
function createAppAndHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
use() {},
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = function (req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const runHandler = (index) => {
|
||||
const handler = routeHandlers[index];
|
||||
if (!handler) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCalled = false;
|
||||
const next = function () {
|
||||
nextCalled = true;
|
||||
return runHandler(index + 1);
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = handler(req, res, next);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(result).then(function (value) {
|
||||
if (!nextCalled) {
|
||||
resolve(value);
|
||||
}
|
||||
}, reject);
|
||||
};
|
||||
|
||||
runHandler(0);
|
||||
});
|
||||
};
|
||||
},
|
||||
post() {},
|
||||
put() {},
|
||||
delete() {}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const headers = {};
|
||||
return {
|
||||
headers,
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
ended: false,
|
||||
set(name, value) {
|
||||
headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
this.ended = true;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayerRouteOptions(overrides) {
|
||||
return Object.assign({
|
||||
mediaDir: 'e:\\Projects Git\\pulse-signage\\media',
|
||||
assetDir: 'e:\\Projects Git\\pulse-signage\\src\\player\\public',
|
||||
playerRuntime: { broadcastAnnouncementRefresh() {}, snapshotConnections() { return []; } },
|
||||
playerPlaylistService: { async buildScreenPlaylist() { return { screen: { slug: 'test2' } }; } },
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
},
|
||||
playerIdentifier: ''
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('screen route falls back to offline rendering when playlist build fails', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const renderCalls = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
const pool = {
|
||||
async query() {
|
||||
return [[{ id: 1 }]];
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
renderPlayerPage(slug, data) {
|
||||
renderCalls.push({ slug, data });
|
||||
return data ? 'online' : 'offline';
|
||||
}
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool,
|
||||
common,
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist() {
|
||||
const error = new Error('db unavailable');
|
||||
error.code = 'ECONNREFUSED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/screen/:slug'];
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
const res = createResponse();
|
||||
|
||||
try {
|
||||
await handler({ params: { slug: 'test2' } }, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(res.headers['X-Player-Offline'], '1');
|
||||
assert.equal(res.body, 'offline');
|
||||
assert.deepEqual(renderCalls, [
|
||||
{ slug: 'test2', data: null }
|
||||
]);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('screen route renders the shell when playlist data is missing', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const renderCalls = [];
|
||||
const pool = {
|
||||
async query() {
|
||||
return [[{ id: 1 }]];
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
renderPlayerPage(slug, data) {
|
||||
renderCalls.push({ slug, data });
|
||||
return '<html><body><div id="app"><div class="empty">Loading screen...</div></div></body></html>';
|
||||
}
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool,
|
||||
common,
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist() {
|
||||
return {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: null,
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'abc123'
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/screen/:slug'];
|
||||
const res = createResponse();
|
||||
|
||||
await handler({ params: { slug: 'test2' } }, res);
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(String(res.body), /Loading screen/);
|
||||
assert.deepEqual(renderCalls, [
|
||||
{
|
||||
slug: 'test2',
|
||||
data: {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: null,
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'abc123'
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('playlist api route returns 404, etag, and 304 responses', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const playlistData = {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: { id: 22 },
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'rev-123'
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: { renderPlayerPage() { return ''; } },
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist(slug) {
|
||||
if (slug === 'missing') {
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'none' };
|
||||
}
|
||||
return playlistData;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/screens/:slug/playlist'];
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
const missingRes = createResponse();
|
||||
await handler({ params: { slug: 'missing' }, headers: {} }, missingRes);
|
||||
assert.equal(missingRes.statusCode, 404);
|
||||
assert.deepEqual(missingRes.body, { error: 'Screen not found' });
|
||||
|
||||
const okRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: {} }, okRes);
|
||||
assert.equal(okRes.statusCode, 200);
|
||||
assert.equal(okRes.headers.ETag, '"rev-123"');
|
||||
assert.deepEqual(okRes.body, playlistData);
|
||||
|
||||
const notModifiedRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"rev-123"' } }, notModifiedRes);
|
||||
assert.equal(notModifiedRes.statusCode, 304);
|
||||
assert.equal(notModifiedRes.ended, true);
|
||||
});
|
||||
|
||||
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const announcement = {
|
||||
id: 9,
|
||||
modified_at: '2026-08-03T00:00:00.000Z',
|
||||
expires_at: '2026-08-04T00:00:00.000Z',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: {
|
||||
async fetchActiveAnnouncement() {
|
||||
const error = new Error('db unavailable');
|
||||
error.code = 'ECONNREFUSED';
|
||||
throw error;
|
||||
},
|
||||
renderPlayerPage() { return ''; }
|
||||
},
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/screens/:slug/announcement'];
|
||||
const unavailableRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: {} }, unavailableRes);
|
||||
assert.equal(unavailableRes.statusCode, 503);
|
||||
assert.deepEqual(unavailableRes.body, { error: 'Announcement state unavailable.' });
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: {
|
||||
async fetchActiveAnnouncement() {
|
||||
return announcement;
|
||||
},
|
||||
renderPlayerPage() { return ''; }
|
||||
},
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const okHandler = handlers['/api/screens/:slug/announcement'];
|
||||
const okRes = createResponse();
|
||||
await okHandler({ params: { slug: 'test2' }, headers: {} }, okRes);
|
||||
assert.equal(okRes.statusCode, 200);
|
||||
assert.equal(okRes.headers.ETag, '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"');
|
||||
assert.deepEqual(okRes.body, { announcement, revision: '9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1' });
|
||||
|
||||
const notModifiedRes = createResponse();
|
||||
await okHandler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"' } }, notModifiedRes);
|
||||
assert.equal(notModifiedRes.statusCode, 304);
|
||||
assert.equal(notModifiedRes.ended, true);
|
||||
});
|
||||
|
||||
test('rtmp session route returns not-ready and ready payloads', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const sessionCalls = [];
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: { renderPlayerPage() { return ''; } },
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus(source, useMutedOutput) {
|
||||
sessionCalls.push({ source, useMutedOutput });
|
||||
if (source === 'ready') {
|
||||
return {
|
||||
ready: true,
|
||||
live: true,
|
||||
session: { key: 'abc', playlistUrl: 'http://example.com/live.m3u8', disableAudio: useMutedOutput }
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: false,
|
||||
live: false,
|
||||
timedOut: true,
|
||||
stderr: 'starting',
|
||||
session: {}
|
||||
};
|
||||
},
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/rtmp/session'];
|
||||
|
||||
const unavailableRes = createResponse();
|
||||
await handler({ headers: {}, query: { source: 'cold', disableAudio: 'true' } }, unavailableRes);
|
||||
assert.equal(unavailableRes.statusCode, 503);
|
||||
assert.deepEqual(unavailableRes.body, { ready: false, live: false, timedOut: true, stderr: 'starting' });
|
||||
|
||||
const readyRes = createResponse();
|
||||
await handler({ headers: {}, query: { source: 'ready', disableAudio: 'yes' } }, readyRes);
|
||||
assert.equal(readyRes.statusCode, 200);
|
||||
assert.deepEqual(readyRes.body, {
|
||||
key: 'abc',
|
||||
playlistUrl: 'http://example.com/live.m3u8',
|
||||
disableAudio: true,
|
||||
ready: true,
|
||||
live: true
|
||||
});
|
||||
assert.deepEqual(sessionCalls, [
|
||||
{ source: 'cold', useMutedOutput: true },
|
||||
{ source: 'ready', useMutedOutput: true }
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createPageAuthToken } = require('../src/request-auth');
|
||||
const { createPlayerRuntime } = require('../src/player/runtime');
|
||||
|
||||
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
|
||||
test.after(() => {
|
||||
if (originalSecret === undefined) {
|
||||
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
} else {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
|
||||
function waitFor(predicate, timeoutMs = 1000) {
|
||||
const start = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const value = predicate();
|
||||
if (value) {
|
||||
return resolve(value);
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
return reject(new Error('Timed out waiting for runtime state.'));
|
||||
}
|
||||
setTimeout(tick, 25);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
test('player runtime snapshots websocket state and checks live names', async () => {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||
|
||||
const runtime = createPlayerRuntime({ pool: null });
|
||||
const server = http.createServer();
|
||||
runtime.installWebsocket(server);
|
||||
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address();
|
||||
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
|
||||
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
client.once('open', resolve);
|
||||
client.once('error', reject);
|
||||
});
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'state',
|
||||
clientId: 'client-1',
|
||||
clientName: ' Lobby Player ',
|
||||
deviceId: 'device 123!?',
|
||||
userAgent: 'Mozilla/5.0 (unit test)',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
page: 'http://localhost:8081/screen/test2',
|
||||
paused: true,
|
||||
blackout: false,
|
||||
currentSlide: { id: 9, title: 'Intro', kind: 'slide', playlistSignature: 'sig' }
|
||||
}));
|
||||
|
||||
await waitFor(() => {
|
||||
const snapshot = runtime.snapshotConnections('test2')[0];
|
||||
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
|
||||
});
|
||||
const snapshot = runtime.snapshotConnections('test2')[0];
|
||||
|
||||
assert.equal(snapshot.clientName, 'Lobby Player');
|
||||
assert.equal(snapshot.deviceId, 'device123');
|
||||
assert.match(snapshot.label, /Lobby Player/);
|
||||
assert.equal(snapshot.paused, true);
|
||||
assert.equal(snapshot.currentSlideId, 9);
|
||||
assert.equal(snapshot.currentSlideTitle, 'Intro');
|
||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
|
||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
|
||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
|
||||
} finally {
|
||||
client.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('player runtime accepts websocket auth from cookies', async () => {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||
|
||||
const runtime = createPlayerRuntime({ pool: null });
|
||||
const server = http.createServer();
|
||||
runtime.installWebsocket(server);
|
||||
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address();
|
||||
const token = createPageAuthToken({ scope: 'player', slug: 'cookie-test' });
|
||||
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/cookie-test`, {
|
||||
headers: {
|
||||
Cookie: `pulse_page_auth=${encodeURIComponent(token)}`
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
client.once('open', resolve);
|
||||
client.once('error', reject);
|
||||
});
|
||||
|
||||
client.send(JSON.stringify({ type: 'state', clientId: 'cookie-client', clientName: 'Cookie Player', deviceId: 'device-cookie' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const snapshot = runtime.snapshotConnections('cookie-test')[0];
|
||||
return snapshot && snapshot.clientName === 'Cookie Player' ? snapshot : null;
|
||||
});
|
||||
|
||||
assert.equal(runtime.snapshotConnections('cookie-test')[0].clientName, 'Cookie Player');
|
||||
} finally {
|
||||
client.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('player runtime sends targeted and broadcast commands to live sockets', async () => {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||
|
||||
const runtime = createPlayerRuntime({ pool: null });
|
||||
const server = http.createServer();
|
||||
runtime.installWebsocket(server);
|
||||
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address();
|
||||
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
|
||||
const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
|
||||
const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
|
||||
|
||||
function openClient(client) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.once('open', resolve);
|
||||
client.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessage(client) {
|
||||
return new Promise((resolve) => {
|
||||
client.once('message', (raw) => {
|
||||
resolve(JSON.parse(String(raw)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([openClient(clientA), openClient(clientB)]);
|
||||
|
||||
clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Alpha', deviceId: 'device-a', page: 'http://localhost:8081/screen/test2' }));
|
||||
clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Beta', deviceId: 'device-b', page: 'http://localhost:8081/screen/test2' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const connections = runtime.snapshotConnections('test2');
|
||||
return connections.length === 2 && connections.every((connection) => connection.clientName);
|
||||
});
|
||||
const [firstConnection, secondConnection] = runtime.snapshotConnections('test2');
|
||||
|
||||
const targetedMessagePromise = waitForMessage(clientA);
|
||||
const targetedCount = await runtime.sendCommandToConnection('test2', firstConnection.id, { action: 'pause' });
|
||||
assert.equal(targetedCount, 1);
|
||||
const targetedMessage = await targetedMessagePromise;
|
||||
assert.equal(targetedMessage.type, 'command');
|
||||
assert.equal(targetedMessage.action, 'pause');
|
||||
assert.equal(targetedMessage.targetConnectionId, firstConnection.id);
|
||||
|
||||
const broadcastPromises = [waitForMessage(clientA), waitForMessage(clientB)];
|
||||
const broadcastCount = await runtime.broadcastCommand('test2', 'resume');
|
||||
assert.equal(broadcastCount, 2);
|
||||
const [broadcastA, broadcastB] = await Promise.all(broadcastPromises);
|
||||
assert.equal(broadcastA.type, 'command');
|
||||
assert.equal(broadcastA.command, 'resume');
|
||||
assert.equal(broadcastB.type, 'command');
|
||||
assert.equal(broadcastB.command, 'resume');
|
||||
assert.equal(broadcastA.targetConnectionId, undefined);
|
||||
assert.equal(broadcastB.targetConnectionId, undefined);
|
||||
|
||||
assert.equal(await runtime.sendCommandToConnection('test2', 'missing', 'pause'), 0);
|
||||
assert.equal(secondConnection.clientName, 'Beta');
|
||||
} finally {
|
||||
clientA.close();
|
||||
clientB.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
createPageAuthBundle,
|
||||
createPageAuthToken,
|
||||
createPageFetchAuthScript,
|
||||
createRequestAuthHeaders,
|
||||
verifyPageAuthToken,
|
||||
verifyRequestAuth,
|
||||
PAGE_TOKEN_HEADER,
|
||||
REQUEST_SIGNATURE_HEADER,
|
||||
REQUEST_TIMESTAMP_HEADER
|
||||
} = require('../src/request-auth');
|
||||
|
||||
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
|
||||
function setSecret(value) {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = value;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (originalSecret === undefined) {
|
||||
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
} else {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
|
||||
test('request auth headers verify empty parser bodies as null payloads', () => {
|
||||
setSecret('test-secret');
|
||||
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => 1700000000000;
|
||||
try {
|
||||
const headers = createRequestAuthHeaders({ method: 'POST', pathname: '/api/media/config', body: null, timestamp: 1700000000000 });
|
||||
const req = {
|
||||
method: 'POST',
|
||||
path: '/api/media/config',
|
||||
body: {},
|
||||
headers: {
|
||||
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
|
||||
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(verifyRequestAuth(req), true);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('page auth bundles round-trip and expire as expected', () => {
|
||||
setSecret('test-secret');
|
||||
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => 1700000000000;
|
||||
try {
|
||||
const bundle = createPageAuthBundle({ scope: 'player', slug: 'test2' });
|
||||
assert.equal(typeof bundle.token, 'string');
|
||||
assert.equal(bundle.issuedAt, 1700000000000);
|
||||
assert.equal(bundle.expiresAt, 1700043200000);
|
||||
assert.deepEqual(verifyPageAuthToken(bundle.token), {
|
||||
scope: 'player',
|
||||
slug: 'test2',
|
||||
issuedAt: 1700000000000,
|
||||
expiresAt: 1700043200000
|
||||
});
|
||||
assert.equal(createPageAuthToken({ scope: 'player' }).split('.').length, 2);
|
||||
|
||||
Date.now = () => 1700043200001;
|
||||
assert.equal(verifyPageAuthToken(bundle.token), null);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('request auth rejects mismatched signatures', () => {
|
||||
setSecret('test-secret');
|
||||
|
||||
const headers = createRequestAuthHeaders({ method: 'PUT', pathname: '/api/media/config', body: { hello: 'world' }, timestamp: 1700000000000 });
|
||||
const req = {
|
||||
method: 'PUT',
|
||||
path: '/api/media/config',
|
||||
body: { hello: 'changed' },
|
||||
headers: {
|
||||
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
|
||||
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(verifyRequestAuth(req), false);
|
||||
assert.equal(PAGE_TOKEN_HEADER, 'x-pulse-page-auth');
|
||||
});
|
||||
|
||||
test('page fetch auth script injects renew and header logic', () => {
|
||||
const script = createPageFetchAuthScript({ token: 'abc123', expiresAt: 1700000000000 });
|
||||
|
||||
assert.match(script, /window\.__pulsePageAuthToken = pageAuthToken/);
|
||||
assert.match(script, /"x-pulse-page-auth"/);
|
||||
assert.match(script, /\/api\/auth\/page/);
|
||||
assert.match(script, /abc123/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildTemplatePayload,
|
||||
extractTemplateRegions
|
||||
} = require('../src/data/templates');
|
||||
|
||||
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
|
||||
const regions = extractTemplateRegions({
|
||||
regions_json: JSON.stringify([
|
||||
{
|
||||
region_name: 'Hero',
|
||||
region_type: 'TEXT',
|
||||
lock_ratio: '16 : 9',
|
||||
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
|
||||
x: '10',
|
||||
y: '20',
|
||||
width: '320',
|
||||
height: '180',
|
||||
z_index: '2'
|
||||
},
|
||||
{
|
||||
region_name: ' ',
|
||||
region_type: 'image'
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
assert.deepEqual(regions, [{
|
||||
region_key: 'Hero',
|
||||
region_type: 'TEXT',
|
||||
label: 'Hero',
|
||||
lock_ratio: '16:9',
|
||||
animation_json: {
|
||||
intro: { preset: 'fadeIn' },
|
||||
outro: { preset: 'bounceOut' },
|
||||
loop: { preset: 'none' }
|
||||
},
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 180,
|
||||
z_index: 2
|
||||
}]);
|
||||
});
|
||||
|
||||
test('buildTemplatePayload resolves canvas size and rejects duplicate region names', async () => {
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
|
||||
assert.deepEqual(params, [4]);
|
||||
return [[{ id: 4, name: 'HD', width: 1280, height: 720 }]];
|
||||
}
|
||||
throw new Error(`unexpected query: ${sql}`);
|
||||
}
|
||||
};
|
||||
|
||||
const payload = await buildTemplatePayload(pool, {
|
||||
body: {
|
||||
name: ' Main Template ',
|
||||
canvas_size_id: '4',
|
||||
background_color: 'not-a-color',
|
||||
regions_json: JSON.stringify([
|
||||
{ region_name: 'Header', region_type: 'text', lock_ratio: '4:3' }
|
||||
])
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
name: 'Main Template',
|
||||
canvasSizeId: 4,
|
||||
canvasSizeWidth: 1280,
|
||||
canvasSizeHeight: 720,
|
||||
backgroundImagePath: null,
|
||||
backgroundColor: '#111111',
|
||||
regions: [{
|
||||
region_key: 'Header',
|
||||
region_type: 'text',
|
||||
label: 'Header',
|
||||
lock_ratio: '4:3',
|
||||
animation_json: {
|
||||
intro: { preset: 'none' },
|
||||
outro: { preset: 'none' },
|
||||
loop: { preset: 'none' }
|
||||
},
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
z_index: 0
|
||||
}]
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => buildTemplatePayload(pool, {
|
||||
body: {
|
||||
name: 'Dupes',
|
||||
regions_json: JSON.stringify([
|
||||
{ region_name: 'One' },
|
||||
{ region_name: 'one' }
|
||||
])
|
||||
},
|
||||
files: []
|
||||
}, null),
|
||||
(error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||
|
||||
test('screen update redirects and forwards redirect when the slug changes', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
},
|
||||
get() {}
|
||||
};
|
||||
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
|
||||
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
||||
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
||||
}
|
||||
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
|
||||
const deps = {
|
||||
pool,
|
||||
common: {
|
||||
slugify(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); },
|
||||
uniqueScreenSlug: async () => 'beta',
|
||||
fetchDuplicateName: async () => null,
|
||||
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
||||
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
|
||||
},
|
||||
pages,
|
||||
getAuditUserId() { return 7; },
|
||||
redirectAfterSave(req, res, url) {
|
||||
calls.push({ kind: 'redirectAfterSave', url });
|
||||
res.redirectedTo = url;
|
||||
},
|
||||
notifyPlayerScreens: async (slugs, payload) => {
|
||||
calls.push({ kind: 'notifyPlayerScreens', slugs, payload });
|
||||
return 1;
|
||||
},
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
getScreenDeleteBlockMessage: async () => '',
|
||||
getScreenConnections: async () => [],
|
||||
forwardPlayerCommand: async (slug, payload) => {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerManageRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/screens/:id'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const req = {
|
||||
params: { id: '42' },
|
||||
body: { name: 'Updated Screen', slug: 'beta' }
|
||||
};
|
||||
const res = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
},
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
assert.equal(res.redirectedTo, '/screens?edit=42');
|
||||
assert.deepEqual(calls, [
|
||||
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
|
||||
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user