Implement video region support

This commit is contained in:
2026-07-26 14:54:50 +01:00
parent 07ec17ad91
commit eb1f33d82f
30 changed files with 1243 additions and 134 deletions
+7
View File
@@ -98,6 +98,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
+5 -5
View File
@@ -15,7 +15,7 @@ function createUploadSyncService(options) {
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null;
@@ -627,6 +627,10 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
@@ -634,10 +638,6 @@ function createUploadSyncService(options) {
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
+39 -1
View File
@@ -759,6 +759,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: block;
}
.slide-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-background {
width: 100%;
height: 100%;
@@ -1014,6 +1021,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
border: 0;
}
.slide-image-region-preview-shell video.slide-image-region-preview {
pointer-events: none;
}
.slide-image-region-preview-empty {
display: flex;
align-items: center;
@@ -1220,7 +1231,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.region-info-card .card-title {
@@ -1680,6 +1690,34 @@ td[data-label="Actions"] > div {
min-width: 6rem;
}
.playlist-duration-input::-webkit-outer-spin-button,
.playlist-duration-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-duration-input {
-moz-appearance: textfield;
appearance: textfield;
}
.playlist-duration-field {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
text-align: left;
}
.playlist-use-video-duration {
white-space: nowrap;
}
td[data-label="Duration"] {
text-align: left;
}
table.table > :not(caption) > * > * {
vertical-align: middle;
}
@@ -1,4 +1,4 @@
(function () {
(function () {
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function formatDays(daysValue) {
@@ -317,6 +317,9 @@
var slidePickerCards = [];
var slidePickerSelection = new Set();
var slidePickerSlides = [];
var videoDurationCache = Object.create(null);
var lastDurationPointerDown = null;
var lastDurationPointerUp = null;
if (!tbody || !form) {
return;
@@ -547,6 +550,11 @@
canvas_width: slide.canvas_width,
canvas_height: slide.canvas_height,
canvas_signature: String(slide.canvasSignature || ''),
showVideoDurationButton: slide.showVideoDurationButton,
videoSourcePath: slide.videoSourcePath,
videoDurationSeconds: slide.videoDurationSeconds,
media_type: slide.media_type,
media_path: slide.media_path,
title: slide.title || 'Slide',
duration_seconds: 10,
schedule_mode: 'always',
@@ -786,6 +794,14 @@
row.setAttribute('data-row-key', rowKey);
row.setAttribute('data-slide-id', String(values.slide_id));
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
row.setAttribute('data-media-type', String(values.media_type || ''));
row.setAttribute('data-media-path', String(values.media_path || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
var durationActionMarkup = Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
? '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (values.useVideoDuration ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (values.useVideoDuration ? 'true' : 'false') + '">' + (values.useVideoDuration ? 'Use Video Duration' : 'Use video duration') + '</button>'
: '';
row.innerHTML = '' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
@@ -808,7 +824,11 @@
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
'<td><div class="playlist-duration-field"><input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
durationActionMarkup +
'</div></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
@@ -816,9 +836,202 @@
return row;
}
function loadVideoDuration(mediaPath) {
var cacheKey = String(mediaPath || '').trim();
var cachedPromise;
if (!cacheKey) {
return Promise.reject(new Error('No video source is available for this slide.'));
}
cachedPromise = videoDurationCache[cacheKey];
if (cachedPromise) {
return cachedPromise;
}
cachedPromise = new Promise(function (resolve, reject) {
var video = document.createElement('video');
var timeoutId = window.setTimeout(function () {
cleanup();
reject(new Error('Timed out loading the video metadata.'));
}, 15000);
function cleanup() {
window.clearTimeout(timeoutId);
video.removeAttribute('src');
video.load();
if (video.parentNode) {
video.parentNode.removeChild(video);
}
}
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.style.position = 'absolute';
video.style.left = '-9999px';
video.style.top = '0';
video.style.width = '1px';
video.style.height = '1px';
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.addEventListener('loadedmetadata', function () {
var duration = Number(video.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(Math.round(duration * 1000) / 1000);
} else {
reject(new Error('The video duration could not be read.'));
}
}, { once: true });
video.addEventListener('error', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
video.addEventListener('abort', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
document.body.appendChild(video);
video.src = cacheKey;
video.load();
}).catch(function (error) {
delete videoDurationCache[cacheKey];
throw error;
});
videoDurationCache[cacheKey] = cachedPromise;
return cachedPromise;
}
function setVideoDurationToggleState(row, button, isPressed) {
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
}
if (button) {
button.classList.toggle('active', Boolean(isPressed));
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
button.textContent = isPressed ? 'Use Video Duration' : 'Use video duration';
}
if (input) {
if (!isPressed && row) {
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
if (previousValue) {
input.value = previousValue;
}
row.removeAttribute('data-video-duration-previous-value');
}
input.disabled = Boolean(isPressed);
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
}
if (flag) {
flag.value = isPressed ? '1' : '0';
}
if (row) {
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
}
if (!row || !input) {
return;
}
if (isPressed) {
if (!mirror) {
mirror = document.createElement('input');
mirror.type = 'hidden';
mirror.setAttribute('data-video-duration-mirror', '');
input.parentNode.insertBefore(mirror, input.nextSibling);
}
mirror.name = input.name;
mirror.value = input.value;
if (input.getAttribute('form')) {
mirror.setAttribute('form', input.getAttribute('form'));
}
} else if (mirror && mirror.parentNode) {
mirror.parentNode.removeChild(mirror);
}
}
async function applyVideoDurationToRow(row, button) {
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
if (!isVideoRow || !input) {
return;
}
if (button && button.getAttribute('aria-pressed') === 'true') {
setVideoDurationToggleState(row, button, false);
return;
}
setVideoDurationToggleState(row, button, true);
if (Number.isFinite(storedDuration) && storedDuration > 0) {
input.value = String(storedDuration);
var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (existingMirror) {
existingMirror.value = input.value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
if (button) {
button.disabled = true;
button.textContent = 'Loading...';
}
try {
var durationSeconds = await loadVideoDuration(mediaPath);
if (row) {
row.setAttribute('data-video-duration-seconds', String(durationSeconds));
}
input.value = String(durationSeconds);
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (mirror) {
mirror.value = input.value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
} catch (error) {
setVideoDurationToggleState(row, button, false);
alert(error && error.message ? error.message : 'Unable to read the video duration.');
} finally {
if (button) {
button.disabled = false;
button.textContent = button.getAttribute('aria-pressed') === 'true' ? 'Use Video Duration' : 'Use video duration';
}
}
}
tbody.addEventListener('click', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
event.stopPropagation();
return;
}
var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]');
var durationButton = event.target.closest('[data-use-video-duration]');
var row = event.target.closest('tr[data-playlist-slide-row]');
if (!row) {
@@ -837,6 +1050,102 @@
if (typeof window.openScheduleModal === 'function') {
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
}
return;
}
if (durationButton) {
var rowKey = row.getAttribute('data-row-key');
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
lastDurationPointerDown = null;
lastDurationPointerUp = null;
event.preventDefault();
return;
}
event.preventDefault();
void applyVideoDurationToRow(row, durationButton);
}
lastDurationPointerDown = null;
lastDurationPointerUp = null;
});
tbody.addEventListener('mousedown', function (event) {
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
if (row && !dragHandle) {
event.stopPropagation();
}
if (durationInput) {
lastDurationPointerDown = {
type: 'input',
rowKey: String(event.target.closest('tr[data-playlist-slide-row]') && event.target.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
event.stopPropagation();
return;
}
if (durationToggle) {
lastDurationPointerDown = {
type: 'button',
rowKey: String(durationToggle.closest('tr[data-playlist-slide-row]') && durationToggle.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
return;
}
lastDurationPointerDown = null;
});
tbody.addEventListener('mouseup', function (event) {
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
if (durationInput) {
lastDurationPointerUp = {
type: 'input',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return;
}
if (durationToggle) {
lastDurationPointerUp = {
type: 'button',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return;
}
lastDurationPointerUp = null;
});
tbody.addEventListener('input', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
var editedRow = event.target.closest('tr[data-playlist-slide-row]');
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
if (editedRow) {
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
}
if (useVideoDuration && editedMirror) {
editedMirror.value = event.target.value;
}
if (editedMirror && !useVideoDuration) {
editedMirror.parentNode.removeChild(editedMirror);
}
markPlaylistDirty();
}
});
+377 -82
View File
@@ -60,6 +60,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
var uploadVideoMaxLabel = '1 GB';
var videoDurationCache = Object.create(null);
var previewRenderFrame = 0;
var previewPopupWindow = null;
var previewPopupRenderFrame = 0;
@@ -255,6 +258,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return current && current.value !== undefined ? current.value : '';
}
function getCurrentRegionVideoDuration(region) {
var current = existingContent[region.region_key] || {};
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? String(duration) : '';
}
function normalizeEditorData(value) {
return String(value || '');
}
@@ -458,6 +467,47 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
function startPreviewVideoPlayback(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video.slide-preview-video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function tryPlay() {
try {
video.load();
} catch (_error) {
// Ignore load failures and try to play anyway.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
if (video.readyState >= 2) {
tryPlay();
return;
}
video.addEventListener('canplay', tryPlay, { once: true });
video.addEventListener('loadedmetadata', tryPlay, { once: true });
});
}
function destroyEditors() {
editorInstances.forEach(function (editor) {
if (editor && typeof editor.destroy === 'function') {
@@ -767,7 +817,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function clearImagePreviewUrls() {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
@@ -775,14 +825,41 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function getRegionMediaType(card) {
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
}
var regionTypeInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
return regionTypeInput && String(regionTypeInput.value || '').trim() === 'video' ? 'video' : 'image';
}
function getRegionMediaPrefix(mediaType) {
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
}
function getRegionExistingMediaPrefix(mediaType) {
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
}
function getRegionExistingVideoDurationName(regionId) {
return 'existing_region_video_duration_' + regionId;
}
function getRegionVideoDurationHiddenInput(regionId) {
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
return card ? card.querySelector('input[type="hidden"][name="' + getRegionExistingVideoDurationName(regionId) + '"]') : null;
}
function getRegionUploadZone(input) {
return input ? input.closest('[data-region-upload-zone]') : null;
}
function getRegionUploadHiddenValue(input) {
var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : '';
var mediaType = input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video)_/, '') : '';
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
return hidden ? hidden.value : '';
}
@@ -847,21 +924,42 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
}
function getRegionMediaTypeFromInput(input) {
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
}
function getRegionMediaUploadLimitBytes(mediaType) {
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxBytes : uploadMaxBytes;
}
function getRegionMediaUploadLimitLabel(mediaType) {
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxLabel : uploadMaxLabel;
}
function isRegionMediaFileWithinLimit(file, mediaType) {
return Boolean(file) && Number(file.size || 0) <= getRegionMediaUploadLimitBytes(mediaType);
}
function rejectOversizeRegionImage(input, file) {
return rejectOversizeRegionMedia(input, file, 'image');
}
function rejectOversizeRegionMedia(input, file, mediaType) {
var zone = getRegionUploadZone(input);
var hiddenValue = getRegionUploadHiddenValue(input);
var limitLabel = getRegionMediaUploadLimitLabel(mediaType || getRegionMediaTypeFromInput(input));
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
window.showToast('File must be ' + limitLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
window.alert('File must be ' + limitLabel + ' or smaller.');
}
input.value = '';
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
renderRegionMediaPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
if (file) {
return false;
}
@@ -870,12 +968,18 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function setRegionImageInputFile(input, file) {
return setRegionMediaInputFile(input, file);
}
function setRegionMediaInputFile(input, file) {
if (!input || !file) {
return;
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
var mediaType = getRegionMediaTypeFromInput(input);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return;
}
@@ -886,18 +990,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function uploadRegionImageFile(input, file) {
return uploadRegionMediaFile(input, file);
}
function uploadRegionMediaFile(input, file) {
if (!input || !file) {
return Promise.resolve(null);
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
var mediaType = getRegionMediaTypeFromInput(input);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return Promise.resolve(null);
}
var regionId = input.name.replace(/^region_image_/, '');
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var zone = getRegionUploadZone(input);
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
@@ -945,7 +1055,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
};
if (!response.ok) {
var error = new Error(response.responseText || 'Unable to upload image.');
var error = new Error(response.responseText || 'Unable to upload media.');
error.status = response.status;
error.responseText = response.responseText;
reject(error);
@@ -961,7 +1071,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var uploadedPath = String(payload.path || '').trim();
if (!uploadedPath) {
reject(new Error('Unable to upload image.'));
reject(new Error('Unable to upload media.'));
return;
}
@@ -977,7 +1087,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
input.dataset.uploadedNeedsCleanup = '1';
input.value = '';
input.setCustomValidity('');
renderImageRegionPreview(card, uploadedPath);
renderRegionMediaPreview(card, uploadedPath);
void syncVideoRegionDuration(card, uploadedPath);
templateSelectorLock.markEdited();
requestPreviewRender();
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
@@ -993,7 +1104,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
reject(new Error('Unable to upload image.'));
reject(new Error('Unable to upload media.'));
};
xhr.onabort = function () {
@@ -1002,7 +1113,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
reject(new Error('Unable to upload image.'));
reject(new Error('Unable to upload media.'));
};
xhr.onloadend = function () {
@@ -1023,7 +1134,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function getPendingUploadCleanupPaths() {
return getRegionImageInputs().map(function (input) {
return getRegionMediaInputs().map(function (input) {
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
return null;
}
@@ -1033,7 +1144,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function clearPendingUploadCleanupPaths() {
getRegionImageInputs().forEach(function (input) {
getRegionMediaInputs().forEach(function (input) {
if (!input) {
return;
}
@@ -1077,7 +1188,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function renderImageRegionPreview(card, value) {
function renderRegionMediaPreview(card, value) {
if (!card) {
return;
}
@@ -1087,13 +1198,148 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
var mediaType = getRegionMediaType(card);
var isVideo = mediaType === 'video';
previewBox.innerHTML = value
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : 'image') + '">' +
(isVideo
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />') +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
if (isVideo) {
window.requestAnimationFrame(function () {
var video = previewBox.querySelector('video.slide-image-region-preview');
if (!video) {
return;
}
try {
video.load();
} catch (_error) {
// Ignore load failures; play() will retry if the browser allows it.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
});
}
}
function loadVideoDuration(mediaPath) {
var cacheKey = String(mediaPath || '').trim();
var cachedPromise;
if (!cacheKey) {
return Promise.reject(new Error('No video source is available for this slide.'));
}
cachedPromise = videoDurationCache[cacheKey];
if (cachedPromise) {
return cachedPromise;
}
cachedPromise = new Promise(function (resolve, reject) {
var video = document.createElement('video');
var timeoutId = window.setTimeout(function () {
cleanup();
reject(new Error('Timed out loading the video metadata.'));
}, 15000);
function cleanup() {
window.clearTimeout(timeoutId);
video.removeAttribute('src');
video.load();
if (video.parentNode) {
video.parentNode.removeChild(video);
}
}
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.style.position = 'absolute';
video.style.left = '-9999px';
video.style.top = '0';
video.style.width = '1px';
video.style.height = '1px';
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.addEventListener('loadedmetadata', function () {
var duration = Number(video.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(Math.max(1, Math.round(duration)));
} else {
reject(new Error('The video duration could not be read.'));
}
}, { once: true });
video.addEventListener('error', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
video.addEventListener('abort', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
document.body.appendChild(video);
video.src = cacheKey;
video.load();
}).catch(function (error) {
delete videoDurationCache[cacheKey];
throw error;
});
videoDurationCache[cacheKey] = cachedPromise;
return cachedPromise;
}
async function syncVideoRegionDuration(card, source) {
if (!card) {
return;
}
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? getRegionVideoDurationHiddenInput(regionId) : null;
if (!hidden) {
return;
}
var mediaPath = String(source || '').trim();
if (!mediaPath) {
hidden.value = '';
return;
}
try {
hidden.value = String(await loadVideoDuration(mediaPath));
} catch (_error) {
hidden.value = '';
}
}
function renderImageRegionPreview(card, value) {
return renderRegionMediaPreview(card, value);
}
function getRegionImageInputs() {
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]'));
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]'));
}
function getRegionMediaInputs() {
return getRegionImageInputs();
}
function hasPendingRegionImageUpload() {
@@ -1102,6 +1348,10 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function hasPendingRegionMediaUpload() {
return hasPendingRegionImageUpload();
}
function getRegionBox(region, canvasWidth, canvasHeight) {
return {
left: Math.max(0, Math.round(Number(region.x || 0))),
@@ -1177,6 +1427,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (popupOverlay) {
popupOverlay.innerHTML = '';
}
startPreviewVideoPlayback(popupCanvas);
return;
}
@@ -1198,6 +1449,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
popupEmpty.style.display = 'none';
}
startPreviewVideoPlayback(popupCanvas);
var stageRect = popupStage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
if (previewPopupRenderFrame) {
@@ -1349,6 +1602,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
var videoHidden = card && card.querySelector('input[type="hidden"][name="existing_region_video_' + region.id + '"]');
var videoFileInput = card && card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
@@ -1361,6 +1616,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var existingApi = existingContent[region.region_key] || {};
var value = region.region_type === 'image'
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
: region.region_type === 'video'
? ((videoFileInput && videoFileInput.dataset.previewUrl) ? videoFileInput.dataset.previewUrl : (videoHidden ? videoHidden.value : ''))
: region.region_type === 'webpage'
? (webpageInput ? webpageInput.value : '')
: region.region_type === 'rtmp'
@@ -1383,6 +1640,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
: getTextStyleFromCard(card, region);
var content = region.region_type === 'image'
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
: region.region_type === 'video'
? (value ? '<video class="slide-preview-video" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>' : '<div class="slide-preview-placeholder">Video</div>')
: region.region_type === 'webpage'
? renderPreviewWebpageRegion(value)
: region.region_type === 'rtmp'
@@ -1407,6 +1666,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
: renderPreviewTextRegion(region, value, style, scale);
var selected = region.region_type === 'image'
? ' slide-preview-image-region'
: region.region_type === 'video'
? ' slide-preview-video-region'
: region.region_type === 'webpage'
? ' slide-preview-webpage-region'
: region.region_type === 'rtmp'
@@ -1421,6 +1682,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
}).join('');
startPreviewVideoPlayback(slidePreviewOverlay);
syncPopupPreview();
}
@@ -1610,7 +1873,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Image</span>' +
@@ -1652,6 +1915,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'</div>';
}
function renderVideoRegion(region) {
var current = getCurrentRegionValue(region);
var currentDuration = getCurrentRegionVideoDuration(region);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Video</span>' +
'</div>' +
'<div class="card-body p-3 d-grid">' +
'<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="video/*" />' +
'<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop a video here or click to upload</strong>' +
'<span>MP4, WebM, or Ogg</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadVideoMaxLabel + ' per file</span>' +
'</span>' +
'</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</div>' +
'</span>' +
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<div class="slide-image-region-preview-box">' +
(current
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
'</div>' +
'</div>' +
'</div>' +
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'<input type="hidden" name="' + getRegionExistingVideoDurationName(region.id) + '" value="' + escapeHtml(currentDuration) + '" />' +
'</div>' +
'</div>';
}
function renderTemplate() {
templateSelectorLock.reset();
clearImagePreviewUrls();
@@ -1667,6 +1977,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (region.region_type === 'image') {
return renderImageRegion(region);
}
if (region.region_type === 'video') {
return renderVideoRegion(region);
}
if (region.region_type === 'webpage') {
return renderWebpageRegion(region);
}
@@ -1685,6 +1998,14 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return renderTextRegion(region);
}).join('') : '<div class="muted">This template has no regions.</div>';
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
if (hidden && hidden.value) {
void syncVideoRegionDuration(card, hidden.value);
}
});
templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
var regionId = holder.getAttribute('data-region-id');
var source = holder.querySelector('.ckeditor-source');
@@ -1792,25 +2113,26 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
});
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
input.addEventListener('change', function () {
var regionId = input.name.replace(/^region_image_/, '');
var mediaType = getRegionMediaTypeFromInput(input);
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var file = input.files && input.files.length ? input.files[0] : null;
if (file && !isRegionImageFileWithinLimit(file)) {
if (file && !isRegionMediaFileWithinLimit(file, mediaType)) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
window.showToast('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
window.alert('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.');
}
renderImageRegionPreview(card, hidden ? hidden.value : '');
renderRegionMediaPreview(card, hidden ? hidden.value : '');
return;
}
@@ -1821,22 +2143,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (file) {
input.dataset.previewUrl = URL.createObjectURL(file);
}
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
renderRegionMediaPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
void syncVideoRegionDuration(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
templateSelectorLock.markEdited();
requestPreviewRender();
if (file) {
uploadRegionImageFile(input, file).catch(function (error) {
uploadRegionMediaFile(input, file).catch(function (error) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof showToast === 'function') {
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
showToast(error && error.message ? error.message : 'Unable to upload media.', 'danger');
} else {
window.alert(error && error.message ? error.message : 'Unable to upload image.');
window.alert(error && error.message ? error.message : 'Unable to upload media.');
}
renderImageRegionPreview(card, hidden ? hidden.value : '');
renderRegionMediaPreview(card, hidden ? hidden.value : '');
void syncVideoRegionDuration(card, hidden ? hidden.value : '');
requestPreviewRender();
});
}
@@ -1844,7 +2168,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
var regionId = zone.getAttribute('data-region-upload-zone');
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var input = zone.querySelector('input[type="file"]');
if (!input) {
return;
}
@@ -1864,24 +2188,27 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
var file = files && files.length ? files[0] : null;
if (!file || !/^image\//i.test(file.type || '')) {
var card = zone.closest('[data-region-id]');
var mediaType = getRegionMediaType(card);
if (!file || !(mediaType === 'video' ? /^video\//i.test(file.type || '') : /^image\//i.test(file.type || ''))) {
return;
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return;
}
setRegionImageInputFile(input, file);
setRegionMediaInputFile(input, file);
});
});
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
templateFields.querySelectorAll('[data-remove-region-image], [data-remove-region-video]').forEach(function (button) {
button.addEventListener('click', function () {
var regionId = button.getAttribute('data-remove-region-image');
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var mediaType = getRegionMediaType(card);
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="' + getRegionMediaPrefix(mediaType) + regionId + '"]');
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
if (input && input.dataset.previewUrl) {
@@ -1899,48 +2226,16 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (hidden) {
hidden.value = '';
}
var durationHidden = card ? getRegionVideoDurationHiddenInput(regionId) : null;
if (durationHidden) {
durationHidden.value = '';
}
renderImageRegionPreview(card, '');
renderRegionMediaPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
shell.addEventListener('click', function () {
var regionId = shell.getAttribute('data-remove-region-image');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
queueUploadCleanup([uploadPath]);
}
if (input) {
input.value = '';
delete input.dataset.uploadedPath;
delete input.dataset.uploadedNeedsCleanup;
}
if (hidden) {
hidden.value = '';
}
renderImageRegionPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
shell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
shell.click();
}
});
});
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
@@ -44,7 +44,7 @@
function getDefaultRegionSize(regionType, lockRatio) {
var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
if (ratio) {
if (ratio.ratio >= 1) {
@@ -312,7 +312,7 @@
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
@@ -334,7 +334,7 @@
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
+65
View File
@@ -1,3 +1,6 @@
const fs = require('fs');
const path = require('path');
module.exports = function registerAdminContentRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -23,6 +26,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const { buildPagination } = require('../../lib/pagination');
const LIST_PAGE_SIZE = 10;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
@@ -43,6 +48,62 @@ module.exports = function registerAdminContentRoutes(app, deps) {
next(error);
}
function getUploadedFileMediaType(file) {
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
return 'video';
}
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
return 'image';
}
return null;
}
function getUploadedFileLimitBytes(file) {
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
}
function getUploadedFileLimitLabel(file) {
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
}
async function removeUploadedFile(file) {
if (!file || !file.filename || !deps.uploadDir) {
return;
}
const filePath = path.join(deps.uploadDir, file.filename);
try {
await fs.promises.unlink(filePath);
} catch (_error) {
// Ignore cleanup failures.
}
}
async function validateUploadedFiles(files) {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const mediaType = getUploadedFileMediaType(file);
if (!mediaType) {
await removeUploadedFile(file);
const error = new Error('Unsupported upload type.');
error.statusCode = 400;
error.expose = true;
throw error;
}
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
await removeUploadedFile(file);
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
error.statusCode = 400;
error.expose = true;
throw error;
}
}
}
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null);
@@ -168,6 +229,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return res.status(400).json({ error: 'No file was uploaded.' });
}
await validateUploadedFiles([req.file]);
res.json({
path: '/media/uploads/' + req.file.filename,
filename: req.file.filename,
@@ -195,6 +258,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
@@ -225,6 +289,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
+14 -5
View File
@@ -116,6 +116,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
@@ -126,6 +127,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
@@ -143,7 +147,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
@@ -188,6 +193,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
@@ -227,12 +233,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
@@ -303,11 +310,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
@@ -323,7 +331,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
+92 -2
View File
@@ -61,6 +61,86 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const directMediaPath = String(slide.media_path || '').trim();
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
return directMediaPath;
}
const contentJson = slide.content_json;
if (!contentJson) {
return directMediaPath;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return directMediaPath;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
} catch (_error) {
return directMediaPath;
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
@@ -75,6 +155,11 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
@@ -91,7 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id)
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
@@ -111,6 +201,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
};
@@ -11,7 +11,7 @@
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Scheduled Tasks</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
<div class="input-group input-group-sm background-tasks-recurring-search">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
</div>
+11 -2
View File
@@ -83,7 +83,7 @@
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-media-type="{{media_type}}" data-media-path="{{media_path}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
@@ -123,7 +123,16 @@
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
</td>
<td data-label="Duration">
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
<div class="playlist-duration-field">
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if useVideoDuration}}disabled{{/if}} />
<input type="hidden" name="use_video_duration[]" value="{{#if useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" />
{{#if useVideoDuration}}
<input type="hidden" name="duration_seconds[]" value="{{durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror />
{{/if}}
{{#if showVideoDurationButton}}
<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration{{#if useVideoDuration}} active{{/if}}" data-use-video-duration aria-pressed="{{#if useVideoDuration}}true{{else}}false{{/if}}">{{#if useVideoDuration}}Use Video Duration{{else}}Use video duration{{/if}}</button>
{{/if}}
</div>
</td>
<td data-label="Actions">
<div class="actions playlist-item-actions">
+2 -2
View File
@@ -113,6 +113,6 @@
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.4"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.4"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.5"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.5"></script>
+1
View File
@@ -116,6 +116,7 @@
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
+1
View File
@@ -123,6 +123,7 @@
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>