Add template animation editor
This commit is contained in:
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 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
|
## 2.4.2 - 2026-08-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
|
|||||||
|
|
||||||
COPY src ./src
|
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
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
Generated
+9
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.1.0",
|
"version": "2.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.0.0",
|
"version": "2.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -755,6 +756,12 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.4.2",
|
"version": "2.5.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
"start:web": "node -r dotenv/config src/web.js",
|
"start:web": "node -r dotenv/config src/web.js",
|
||||||
"start:player": "node -r dotenv/config src/player.js",
|
"start:player": "node -r dotenv/config src/player.js",
|
||||||
"dev:web": "nodemon -r dotenv/config src/web.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": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
+61
-2
@@ -1,6 +1,7 @@
|
|||||||
// Template data access helpers and region normalization logic.
|
// Template data access helpers and region normalization logic.
|
||||||
|
|
||||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||||
|
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||||
|
|
||||||
function sanitizeBackgroundColor(value) {
|
function sanitizeBackgroundColor(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
@@ -23,6 +24,61 @@ function normalizeTemplateRegionLockRatio(value) {
|
|||||||
return rawRatio.replace(/\s+/g, '');
|
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) {
|
function normalizeTemplateRegionName(value) {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -57,7 +113,7 @@ async function fetchTemplateById(pool, id) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const template = templates[0];
|
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;
|
template.regions = regions;
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
@@ -70,7 +126,7 @@ async function fetchTemplatesData(pool) {
|
|||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC
|
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 };
|
return { templates, templateRegions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +142,7 @@ function extractTemplateRegions(body) {
|
|||||||
region_type: regionType,
|
region_type: regionType,
|
||||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||||
|
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||||
x: Number(region.x || 0),
|
x: Number(region.x || 0),
|
||||||
y: Number(region.y || 0),
|
y: Number(region.y || 0),
|
||||||
width: Number(region.width || 100),
|
width: Number(region.width || 100),
|
||||||
@@ -101,6 +158,7 @@ function extractTemplateRegions(body) {
|
|||||||
const labels = readFormArray(body, 'region_label[]');
|
const labels = readFormArray(body, 'region_label[]');
|
||||||
const types = readFormArray(body, 'region_type[]');
|
const types = readFormArray(body, 'region_type[]');
|
||||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||||
|
const animationJsons = readFormArray(body, 'region_animation_json[]');
|
||||||
const xs = readFormArray(body, 'region_x[]');
|
const xs = readFormArray(body, 'region_x[]');
|
||||||
const ys = readFormArray(body, 'region_y[]');
|
const ys = readFormArray(body, 'region_y[]');
|
||||||
const widths = readFormArray(body, 'region_width[]');
|
const widths = readFormArray(body, 'region_width[]');
|
||||||
@@ -120,6 +178,7 @@ function extractTemplateRegions(body) {
|
|||||||
region_type: regionType,
|
region_type: regionType,
|
||||||
label: name,
|
label: name,
|
||||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||||
|
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
||||||
x: Number(xs[i] || 0),
|
x: Number(xs[i] || 0),
|
||||||
y: Number(ys[i] || 0),
|
y: Number(ys[i] || 0),
|
||||||
width: Number(widths[i] || 100),
|
width: Number(widths[i] || 100),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async function ensureSchema(pool, options) {
|
|||||||
label VARCHAR(255) NOT NULL,
|
label VARCHAR(255) NOT NULL,
|
||||||
font_family VARCHAR(100) NULL,
|
font_family VARCHAR(100) NULL,
|
||||||
lock_ratio VARCHAR(20) NULL,
|
lock_ratio VARCHAR(20) NULL,
|
||||||
|
animation_json JSON NULL,
|
||||||
x INT NOT NULL DEFAULT 0,
|
x INT NOT NULL DEFAULT 0,
|
||||||
y INT NOT NULL DEFAULT 0,
|
y INT NOT NULL DEFAULT 0,
|
||||||
width INT NOT NULL DEFAULT 100,
|
width INT NOT NULL DEFAULT 100,
|
||||||
|
|||||||
+14
-3
@@ -107,9 +107,7 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
INDEX idx_announcements_modified_at (modified_at)
|
INDEX idx_announcements_modified_at (modified_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: '2.4.0',
|
version: '2.4.0',
|
||||||
@@ -217,6 +215,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) {
|
async function columnExists(pool, tableName, columnName) {
|
||||||
@@ -260,7 +265,13 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
|
|||||||
|
|
||||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||||
if (await columnExists(pool, tableName, columnName)) {
|
if (await columnExists(pool, tableName, columnName)) {
|
||||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// Announcement overlay polling and rendering helpers.
|
// Announcement overlay polling and rendering helpers.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var announcementLayer = null;
|
var announcementLayer = null;
|
||||||
var announcementRefreshTimer = null;
|
var announcementRefreshTimer = null;
|
||||||
var announcementPollTimer = null;
|
var announcementPollTimer = null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>{{TITLE}}</title>
|
<title>{{TITLE}}</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
<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/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" />
|
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||||
{{{STYLESHEETS}}}
|
{{{STYLESHEETS}}}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
WHERE st.id IN (?)
|
WHERE st.id IN (?)
|
||||||
`, [templateIds]);
|
`, [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) {
|
templateRows.forEach(function (template) {
|
||||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||||
templatesById[template.id] = template;
|
templatesById[template.id] = template;
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ body.onboarding-page #app {
|
|||||||
display: none;
|
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 {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function getCurrentViewport() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var slideOutroTimers = [];
|
||||||
|
|
||||||
// Command websocket and player-state helpers.
|
// Command websocket and player-state helpers.
|
||||||
// Send the current playback state to the command websocket.
|
// Send the current playback state to the command websocket.
|
||||||
function sendCommandState(currentSlide) {
|
function sendCommandState(currentSlide) {
|
||||||
@@ -65,6 +67,56 @@ function clearSlideTimer() {
|
|||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
timer = null;
|
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.
|
// Schedule the next slide transition.
|
||||||
@@ -72,10 +124,12 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
clearSlideTimer();
|
clearSlideTimer();
|
||||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
slideExpiresAt = Date.now() + holdDelayMs;
|
slideExpiresAt = Date.now() + holdDelayMs;
|
||||||
|
scheduleSlideOutro(holdDelayMs);
|
||||||
timer = window.setTimeout(function () {
|
timer = window.setTimeout(function () {
|
||||||
timer = null;
|
timer = null;
|
||||||
slideExpiresAt = null;
|
slideExpiresAt = null;
|
||||||
pausedRemainingMs = null;
|
pausedRemainingMs = null;
|
||||||
|
clearSlideOutroTimer();
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
if (activeSlides.length < 2) {
|
if (activeSlides.length < 2) {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -89,13 +143,10 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
}, holdDelayMs);
|
}, 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) {
|
function getSlideHoldDelay(delayMs) {
|
||||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
if (!currentPlaylistFadeBetweenSlides) {
|
return holdDelayMs;
|
||||||
return holdDelayMs;
|
|
||||||
}
|
|
||||||
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any pending fade-transition cleanup.
|
// Cancel any pending fade-transition cleanup.
|
||||||
@@ -114,11 +165,19 @@ function getPlayerRegionModules() {
|
|||||||
return window.pulsePlayerRegionTypes.list();
|
return window.pulsePlayerRegionTypes.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isThumbnailPreview() {
|
||||||
|
return Boolean(window.__pulseThumbnailPreview);
|
||||||
|
}
|
||||||
|
|
||||||
function runRegionLifecycle(root, lifecycleName) {
|
function runRegionLifecycle(root, lifecycleName) {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
getPlayerRegionModules().forEach(function (entry) {
|
getPlayerRegionModules().forEach(function (entry) {
|
||||||
var module = entry && entry.definition ? entry.definition : null;
|
var module = entry && entry.definition ? entry.definition : null;
|
||||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||||
@@ -208,8 +267,15 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(app);
|
syncRtmpRegions(app);
|
||||||
}
|
}
|
||||||
initializeRegionInstances(app);
|
if (!isThumbnailPreview()) {
|
||||||
|
initializeRegionInstances(app);
|
||||||
|
}
|
||||||
initializeRenderedVideoPlayback(app);
|
initializeRenderedVideoPlayback(app);
|
||||||
|
if (typeof playRegionAnimations === 'function') {
|
||||||
|
window.requestAnimationFrame(function () {
|
||||||
|
playRegionAnimations(app, 'intro');
|
||||||
|
});
|
||||||
|
}
|
||||||
return app.firstElementChild;
|
return app.firstElementChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,14 +343,18 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
window.requestAnimationFrame(function () {
|
window.requestAnimationFrame(function () {
|
||||||
nextShell.style.opacity = '1';
|
nextShell.style.opacity = '1';
|
||||||
previousShell.style.opacity = '0';
|
previousShell.style.opacity = '0';
|
||||||
|
if (typeof playRegionAnimations === 'function') {
|
||||||
|
playRegionAnimations(nextShell, 'intro');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(nextShell);
|
syncRtmpRegions(nextShell);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeRegionInstances(nextShell);
|
if (!isThumbnailPreview()) {
|
||||||
|
initializeRegionInstances(nextShell);
|
||||||
|
}
|
||||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||||
|
|
||||||
slideTransitionTimer = window.setTimeout(function () {
|
slideTransitionTimer = window.setTimeout(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) {
|
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||||
if (!document || !document.documentElement) {
|
if (!document || !document.documentElement) {
|
||||||
return;
|
return;
|
||||||
@@ -474,6 +744,7 @@ function getTemplateLayout(template) {
|
|||||||
regionKey: region.region_key,
|
regionKey: region.region_key,
|
||||||
regionType: region.region_type,
|
regionType: region.region_type,
|
||||||
label: region.label,
|
label: region.label,
|
||||||
|
animationJson: region.animation_json || null,
|
||||||
baseStyle: baseStyle,
|
baseStyle: baseStyle,
|
||||||
pixelWidth: pixelWidth,
|
pixelWidth: pixelWidth,
|
||||||
pixelHeight: pixelHeight,
|
pixelHeight: pixelHeight,
|
||||||
@@ -578,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
|
|||||||
const layout = plan ? plan.layout : null;
|
const layout = plan ? plan.layout : null;
|
||||||
const regions = layout ? layout.regions.map(function (region) {
|
const regions = layout ? layout.regions.map(function (region) {
|
||||||
const regionContent = content[region.regionKey] || {};
|
const regionContent = content[region.regionKey] || {};
|
||||||
return plan.renderRegion(region, regionContent);
|
const markup = plan.renderRegion(region, regionContent);
|
||||||
|
return decorateRegionMarkup(markup, {
|
||||||
|
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||||
|
});
|
||||||
}).join('') : '';
|
}).join('') : '';
|
||||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||||
if (layout) {
|
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 hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||||
|
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
||||||
const script = getPlayerPageScript()({
|
const script = getPlayerPageScript()({
|
||||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||||
@@ -128,6 +129,7 @@ function renderPlayerPage(slug, initialData) {
|
|||||||
|
|
||||||
return renderPage(template, {
|
return renderPage(template, {
|
||||||
title: 'Screen ' + slug,
|
title: 'Screen ' + slug,
|
||||||
|
bodyClass: bodyClass,
|
||||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
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) + 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>'
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ function registerPartials(Handlebars, viewsRoot) {
|
|||||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
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/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/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/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'));
|
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;
|
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 {
|
.announcement-icon-picker {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1401,7 +1477,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
/* overflow: hidden; */
|
overflow: visible;
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
@@ -1431,11 +1507,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
.designer-overlay {
|
.designer-overlay {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
--designer-overlay-scale: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect {
|
.designer-rect {
|
||||||
position: absolute;
|
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);
|
background: rgba(13, 110, 253, 0.12);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -1445,20 +1526,20 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect.selected {
|
.designer-rect.selected {
|
||||||
border-color: rgba(25, 135, 84, 1);
|
outline-color: rgba(25, 135, 84, 1);
|
||||||
background: rgba(25, 135, 84, 0.14);
|
background: rgba(25, 135, 84, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect-label {
|
.designer-rect-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.4rem;
|
left: calc(6px * var(--designer-overlay-scale, 1));
|
||||||
top: 0.35rem;
|
top: calc(6px * var(--designer-overlay-scale, 1));
|
||||||
max-width: 100%;
|
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;
|
border-radius: 999px;
|
||||||
background: rgba(33, 37, 41, 0.92);
|
background: rgba(33, 37, 41, 0.92);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.75rem;
|
font-size: calc(0.75rem * var(--designer-overlay-scale, 1));
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1471,8 +1552,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 0.8rem;
|
z-index: 4;
|
||||||
height: 0.8rem;
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
background: rgba(13, 110, 253, 1);
|
background: rgba(13, 110, 253, 1);
|
||||||
@@ -1482,28 +1565,28 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
.resize-handle.nw {
|
.resize-handle.nw {
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-40%, -40%);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.ne {
|
.resize-handle.ne {
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translate(50%, -50%);
|
transform: translate(40%, -40%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.sw {
|
.resize-handle.sw {
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
transform: translate(-50%, 50%);
|
transform: translate(-40%, 40%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.se {
|
.resize-handle.se {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
transform: translate(50%, 50%);
|
transform: translate(40%, 40%);
|
||||||
cursor: nwse-resize;
|
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));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.region-field-grid--animation {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.region-field-grid > label {
|
.region-field-grid > label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -1877,7 +1964,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
|
|
||||||
.region-field-grid--identity,
|
.region-field-grid--identity,
|
||||||
.region-field-grid--position,
|
.region-field-grid--position,
|
||||||
.region-field-grid--size {
|
.region-field-grid--size,
|
||||||
|
.region-field-grid--animation {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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) {
|
function syncRegionIdentity(card, value) {
|
||||||
var next = String(value || '').trim();
|
var next = String(value || '').trim();
|
||||||
card.querySelector('[name="region_name[]"]').value = next;
|
card.querySelector('[name="region_name[]"]').value = next;
|
||||||
@@ -96,6 +264,7 @@
|
|||||||
label: name,
|
label: name,
|
||||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||||
|
animation_json: getAnimationConfig(card),
|
||||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||||
width: Number(card.querySelector('[name="region_width[]"]').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.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.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.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.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); }
|
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||||
@@ -171,6 +341,10 @@
|
|||||||
syncRegionIdentity: syncRegionIdentity,
|
syncRegionIdentity: syncRegionIdentity,
|
||||||
readCard: readCard,
|
readCard: readCard,
|
||||||
writeCard: writeCard,
|
writeCard: writeCard,
|
||||||
|
normalizeAnimationConfig: normalizeAnimationConfig,
|
||||||
|
formatAnimationConfig: formatAnimationConfig,
|
||||||
|
getAnimationConfig: getAnimationConfig,
|
||||||
|
writeAnimationConfig: writeAnimationConfig,
|
||||||
clampRegion: clampRegion,
|
clampRegion: clampRegion,
|
||||||
getOverlayRect: getOverlayRect,
|
getOverlayRect: getOverlayRect,
|
||||||
toCanvasPoint: toCanvasPoint,
|
toCanvasPoint: toCanvasPoint,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
: (Array.isArray(templateData) ? templateData : []);
|
: (Array.isArray(templateData) ? templateData : []);
|
||||||
var stage = document.getElementById('designer-stage');
|
var stage = document.getElementById('designer-stage');
|
||||||
var overlay = document.getElementById('designer-overlay');
|
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 regionList = document.getElementById('region-list');
|
||||||
var regionSelect = document.getElementById('region-select');
|
var regionSelect = document.getElementById('region-select');
|
||||||
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
||||||
@@ -36,6 +40,7 @@
|
|||||||
var addRegionButton = document.getElementById('add-region-button');
|
var addRegionButton = document.getElementById('add-region-button');
|
||||||
var regionAddModal = document.getElementById('region-add-modal');
|
var regionAddModal = document.getElementById('region-add-modal');
|
||||||
var regionAddOptions = document.getElementById('region-add-options');
|
var regionAddOptions = document.getElementById('region-add-options');
|
||||||
|
var animationAdvancedModal = document.getElementById('animation-advanced-modal');
|
||||||
var regionCardTemplate = document.getElementById('region-card-template');
|
var regionCardTemplate = document.getElementById('region-card-template');
|
||||||
var regionsJsonInput = document.getElementById('regions-json');
|
var regionsJsonInput = document.getElementById('regions-json');
|
||||||
var templateForm = document.getElementById('template-form');
|
var templateForm = document.getElementById('template-form');
|
||||||
@@ -43,7 +48,20 @@
|
|||||||
var regionUsageSet = new Set();
|
var regionUsageSet = new Set();
|
||||||
var draft = null;
|
var draft = null;
|
||||||
var selectedIndex = -1;
|
var selectedIndex = -1;
|
||||||
|
var activeAnimationCard = null;
|
||||||
var overlayRenderFrame = 0;
|
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 {
|
try {
|
||||||
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||||
@@ -218,6 +236,7 @@
|
|||||||
label: getRegionName(card),
|
label: getRegionName(card),
|
||||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||||
|
animation_json: getAnimationConfig(card),
|
||||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||||
width: Number(card.querySelector('[name="region_width[]"]').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) {
|
function writeCard(card, values) {
|
||||||
if (utils.writeCard) {
|
if (utils.writeCard) {
|
||||||
utils.writeCard(card, values);
|
utils.writeCard(card, values);
|
||||||
@@ -240,6 +835,7 @@
|
|||||||
}
|
}
|
||||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
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.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.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.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); }
|
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||||
@@ -401,6 +997,7 @@
|
|||||||
if (regionLockRatioInput) {
|
if (regionLockRatioInput) {
|
||||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
|
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_x[]"]').value = valueOrDefault(region.x, 80);
|
||||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||||
@@ -452,6 +1049,9 @@
|
|||||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
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 () {
|
nameInput.addEventListener('input', function () {
|
||||||
syncRegionIdentity(card, nameInput.value);
|
syncRegionIdentity(card, nameInput.value);
|
||||||
updateRegionLabel(card);
|
updateRegionLabel(card);
|
||||||
@@ -459,6 +1059,28 @@
|
|||||||
renderRegionSidebar();
|
renderRegionSidebar();
|
||||||
renderOverlay();
|
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) {
|
if (lockRatioInput) {
|
||||||
lockRatioInput.addEventListener('input', function () {
|
lockRatioInput.addEventListener('input', function () {
|
||||||
updateRegionLockBadge(card);
|
updateRegionLockBadge(card);
|
||||||
@@ -536,28 +1158,49 @@
|
|||||||
regionSelect.value = String(selectedIndex);
|
regionSelect.value = String(selectedIndex);
|
||||||
updateCanvasSizeLock();
|
updateCanvasSizeLock();
|
||||||
validateRegionNames();
|
validateRegionNames();
|
||||||
|
updatePreviewCardVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverlay() {
|
function renderOverlay() {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var cards = getCards();
|
var cards = getCards();
|
||||||
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
||||||
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
||||||
var regions = cards.map(readCard);
|
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);
|
regionsJsonInput.value = JSON.stringify(regions);
|
||||||
overlay.innerHTML = regions.map(function (region, index) {
|
overlay.innerHTML = regions.map(function (region, index) {
|
||||||
var box = canvasRectToPixels(region);
|
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' : '';
|
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('');
|
}).join('');
|
||||||
if (draft) {
|
if (draft) {
|
||||||
var rect = getOverlayRect();
|
var rect = overlayRect;
|
||||||
var size = getCanvasSize();
|
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) };
|
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() {
|
function requestOverlayRender() {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (overlayRenderFrame) {
|
if (overlayRenderFrame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -575,6 +1218,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSelected(index) {
|
function setSelected(index) {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var cards = getCards();
|
var cards = getCards();
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
selectedIndex = -1;
|
selectedIndex = -1;
|
||||||
@@ -588,6 +1235,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addRegion(region) {
|
function addRegion(region) {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var hint = regionList.querySelector('.muted');
|
var hint = regionList.querySelector('.muted');
|
||||||
if (hint) {
|
if (hint) {
|
||||||
hint.remove();
|
hint.remove();
|
||||||
@@ -613,6 +1264,7 @@
|
|||||||
region_key: name,
|
region_key: name,
|
||||||
label: name,
|
label: name,
|
||||||
region_type: type,
|
region_type: type,
|
||||||
|
animation_json: normalizeAnimationConfig('{}'),
|
||||||
x: 80,
|
x: 80,
|
||||||
y: 80,
|
y: 80,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
@@ -639,6 +1291,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDraw(event) {
|
function startDraw(event) {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var start = toCanvasPoint(event);
|
var start = toCanvasPoint(event);
|
||||||
draft = { start: start, end: start };
|
draft = { start: start, end: start };
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
@@ -664,6 +1320,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startMove(index, event) {
|
function startMove(index, event) {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var startPoint = toCanvasPoint(event);
|
var startPoint = toCanvasPoint(event);
|
||||||
var startRegion = readCard(cardAt(index));
|
var startRegion = readCard(cardAt(index));
|
||||||
function moveHandler(moveEvent) {
|
function moveHandler(moveEvent) {
|
||||||
@@ -687,6 +1347,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resizeFromHandle(index, dir, event) {
|
function resizeFromHandle(index, dir, event) {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var startPoint = toCanvasPoint(event);
|
var startPoint = toCanvasPoint(event);
|
||||||
var startRegion = readCard(cardAt(index));
|
var startRegion = readCard(cardAt(index));
|
||||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||||
@@ -824,6 +1488,31 @@
|
|||||||
backgroundEmpty.style.display = 'block';
|
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) {
|
if (backgroundColorInput) {
|
||||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
||||||
}
|
}
|
||||||
@@ -853,6 +1542,11 @@
|
|||||||
});
|
});
|
||||||
if (templateForm) {
|
if (templateForm) {
|
||||||
templateForm.addEventListener('formdata', function (event) {
|
templateForm.addEventListener('formdata', function (event) {
|
||||||
|
if (previewState.active) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!validateRegionNames()) {
|
if (!validateRegionNames()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -868,6 +1562,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
templateForm.addEventListener('submit', function () {
|
templateForm.addEventListener('submit', function () {
|
||||||
|
if (previewState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!validateRegionNames()) {
|
if (!validateRegionNames()) {
|
||||||
return;
|
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);
|
renderRegionList(existingRegions);
|
||||||
syncCanvasSizeSelection();
|
syncCanvasSizeSelection();
|
||||||
updateStageBackgroundColor();
|
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) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
const region = payload.regions[i];
|
||||||
await pool.query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
[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({
|
await syncPlaylistUploadsOnChange({
|
||||||
@@ -563,8 +563,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
const region = payload.regions[i];
|
||||||
await pool.query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
[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({
|
await syncPlaylistUploadsOnChange({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Shared template form view-model builder.
|
// Shared template form view-model builder.
|
||||||
|
|
||||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||||
|
const animationPresets = require('../../../public/js/templates/animation-presets');
|
||||||
|
|
||||||
function buildDefaultTemplate() {
|
function buildDefaultTemplate() {
|
||||||
return {
|
return {
|
||||||
@@ -61,7 +62,8 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
|||||||
cancelUrl: '/templates',
|
cancelUrl: '/templates',
|
||||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||||
canvasSizes: canvasSizes || [],
|
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/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.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/css/theme-custom.css?v={{appVersion}}" />
|
||||||
|
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
|
||||||
{{#if stylesheets.length}}
|
{{#if stylesheets.length}}
|
||||||
{{#each stylesheets}}
|
{{#each stylesheets}}
|
||||||
<link rel="stylesheet" href="{{assetHref this}}" />
|
<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>
|
||||||
<div class="template-designer-sidebar">
|
<div class="template-designer-sidebar">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<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">
|
<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}}}
|
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
|
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
|
||||||
@@ -148,10 +155,57 @@
|
|||||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
<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>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>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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
{{> signage/templates/animation-advanced-modal}}
|
||||||
|
|
||||||
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
<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,156 @@
|
|||||||
|
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 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