diff --git a/src/data/admin.js b/src/data/admin.js
index 069f9b4..2bc441f 100644
--- a/src/data/admin.js
+++ b/src/data/admin.js
@@ -25,7 +25,7 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
- SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
+ SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
diff --git a/src/data/slides.js b/src/data/slides.js
index e3f8f33..e09f37e 100644
--- a/src/data/slides.js
+++ b/src/data/slides.js
@@ -92,6 +92,18 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'image',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
+ } else if (region.region_type === 'video') {
+ const uploaded = filesByField[`region_video_${region.id}`];
+ const existing = body[`existing_region_video_${region.id}`];
+ const durationValue = body[`existing_region_video_duration_${region.id}`];
+ const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
+ const parsedDuration = Number(durationValue || existingDuration || 0);
+ const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
+ content[region.region_key] = {
+ type: 'video',
+ value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
+ duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
+ };
} else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
diff --git a/src/data/templates.js b/src/data/templates.js
index a9ac865..05b94dc 100644
--- a/src/data/templates.js
+++ b/src/data/templates.js
@@ -1,6 +1,6 @@
const { parseJsonSafe, readFormArray } = require('./utils');
-const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
+const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
function sanitizeBackgroundColor(value) {
diff --git a/src/db/index.js b/src/db/index.js
index 4ca69eb..b384e56 100644
--- a/src/db/index.js
+++ b/src/db/index.js
@@ -117,7 +117,8 @@ async function ensureSchema(pool, options) {
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
- duration_seconds INT NOT NULL DEFAULT 10,
+ duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
+ use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
diff --git a/src/db/migrations.js b/src/db/migrations.js
index 547a5ab..4f23bbc 100644
--- a/src/db/migrations.js
+++ b/src/db/migrations.js
@@ -605,20 +605,20 @@ const migrations = [
{
key: 'schema-columns-current',
version: appVersion,
- comment: 'Add the current table columns and audit fields that define the released schema.',
+ comment: 'Backfill the released schema columns for older databases.',
order: 20,
up: async function (pool) {
- // v1.4.6: keep the current canvas_sizes shape available in older databases.
+ // Current released schema: keep canvas_sizes audit fields available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
- // v1.4.6: playlists gained fade_between_slides plus audit fields.
+ // Current released schema: ensure playlists carry the playback and audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
- // v1.4.6: slide_templates now store canvas sizing and background metadata.
+ // Current released schema: keep slide template canvas and background fields available.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
@@ -626,13 +626,13 @@ const migrations = [
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
- // v1.4.6: slide_template_regions gained font family and audit fields.
+ // Current released schema: keep slide template region typography and audit fields available.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
- // v1.4.6: slides gained structured content and media fields.
+ // Current released schema: keep structured slide content and media fields available.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
@@ -642,8 +642,8 @@ const migrations = [
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
- // v1.4.6: playlist_slides gained scheduling fields and audit fields.
- await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
+ // Current released schema: keep playlist slide duration, schedule, and audit fields available.
+ await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
@@ -653,12 +653,12 @@ const migrations = [
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
- // v1.4.6: screens gained an optional playlist binding and audit fields.
+ // Current released schema: keep screen playlist bindings and audit fields available.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
- // v1.4.6: RSS feeds now use a neutral interval value plus unit.
+ // Current released schema: keep RSS feed interval and audit fields available.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -667,14 +667,14 @@ const migrations = [
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
- // v1.4.6: rss_feed_items stores normalized JSON snapshots.
+ // Current released schema: keep normalized RSS feed item snapshots available.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
- // v1.4.6: API sources now track their latest response snapshot.
+ // Current released schema: keep API source polling and snapshot fields available.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -687,7 +687,7 @@ const migrations = [
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
- // v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
+ // Current released schema: keep onboarding device and RBAC audit fields available.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
@@ -741,6 +741,24 @@ const migrations = [
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
}
},
+ {
+ key: 'playlist-slide-use-video-duration-flag',
+ version: appVersion,
+ comment: 'Add the playlist slide flag that follows the current video duration.',
+ order: 23,
+ up: async function (pool) {
+ await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
+ }
+ },
+ {
+ key: 'playlist-slide-duration-decimals',
+ version: appVersion,
+ comment: 'Widen playlist slide durations to preserve fractional seconds.',
+ order: 24,
+ up: async function (pool) {
+ await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
+ }
+ },
{
key: 'media-path-prefix-rename',
version: appVersion,
diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html
index 30cbd44..5c7529f 100644
--- a/src/player/player-page.script.html
+++ b/src/player/player-page.script.html
@@ -13,6 +13,7 @@
let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null);
+ let videoRegionRenderVersion = 0;
let renderCacheViewportKey = '';
let index = 0;
let timer = null;
diff --git a/src/player/playlist.js b/src/player/playlist.js
index 0662167..4fbfee7 100644
--- a/src/player/playlist.js
+++ b/src/player/playlist.js
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
return payloadWithoutPlaylist;
}
- const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
+ const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
- SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
- ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
+ SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
+ ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
@@ -108,12 +108,46 @@ function createPlayerPlaylistService(options) {
});
}
+ function getVideoRegionDurationSeconds(contentJson) {
+ if (!contentJson) {
+ return null;
+ }
+
+ try {
+ const parsed = common.parseJsonSafe(contentJson) || {};
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+
+ const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (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;
+ }
+ }
+
const slides = slideRows.map(function (slide) {
+ const storedDuration = Number(slide.duration_seconds || 0);
+ const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
+ const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
+ const content = common.parseJsonSafe(slide.content_json) || {};
+ Object.keys(content).forEach(function (key) {
+ const region = content[key];
+ if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
+ region.cache_bust = videoCacheBust;
+ }
+ });
return {
id: slide.id,
title: slide.title,
body: slide.body,
- duration_seconds: slide.duration_seconds,
+ modified_at: slide.modified_at,
+ duration_seconds: videoDuration || storedDuration,
+ use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
- content: common.parseJsonSafe(slide.content_json) || {}
+ content: content
};
});
@@ -193,6 +227,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
+ updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css
index 6aa1141..c26ced9 100644
--- a/src/player/public/css/player.css
+++ b/src/player/public/css/player.css
@@ -364,6 +364,19 @@ body.screen-blackout #app {
display: block;
}
+.template-region.video {
+ background: transparent;
+}
+
+.template-region.video video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+ pointer-events: none;
+ background: transparent;
+}
+
.template-region.webpage iframe {
width: 100%;
height: 100%;
diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js
index 5b00f92..8745a93 100644
--- a/src/player/public/js/player-page-commands.js
+++ b/src/player/public/js/player-page-commands.js
@@ -89,10 +89,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
-// Add the fade time to a slide's hold duration so the configured duration remains visible.
-function getSlideHoldDelay(delayMs) {
+// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
+function getSlideHoldDelay(delayMs, skipFadePadding) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
- return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
+ return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
}
// Cancel any pending fade-transition cleanup.
@@ -109,11 +109,48 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
+
+ function initializeRenderedVideoPlayback(root) {
+ if (!root) {
+ return;
+ }
+
+ var videos = root.querySelectorAll('.template-region.video video');
+ Array.prototype.forEach.call(videos, function (video) {
+ if (!video) {
+ return;
+ }
+
+ video.autoplay = true;
+ video.loop = true;
+ video.muted = true;
+ video.playsInline = true;
+
+ function startPlayback() {
+ var playPromise = video.play && video.play();
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch(function () {
+ return null;
+ });
+ }
+ }
+
+ if (video.readyState >= 2) {
+ startPlayback();
+ return;
+ }
+
+ video.addEventListener('canplay', startPlayback, { once: true });
+ video.addEventListener('loadedmetadata', startPlayback, { once: true });
+ });
+ }
+
if (!shouldFade) {
app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
+ initializeRenderedVideoPlayback(app);
return app.firstElementChild;
}
@@ -143,6 +180,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
+ initializeRenderedVideoPlayback(nextShell);
return nextShell;
}
@@ -162,6 +200,8 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell);
}
+ initializeRenderedVideoPlayback(nextShell);
+
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js
index b9f7e70..b26036c 100644
--- a/src/player/public/js/player-page-playback.js
+++ b/src/player/public/js/player-page-playback.js
@@ -147,7 +147,7 @@ function refresh() {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
- scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
+ scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index 3e5fc43..77d2ba9 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -452,6 +452,9 @@ function getTemplateRenderPlan(template) {
if (region.regionType === 'image') {
return renderImageRegion(region, regionContent);
}
+ if (region.regionType === 'video') {
+ return renderVideoRegion(region, regionContent);
+ }
if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent);
}
@@ -517,9 +520,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
// Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
- return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
+ return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
}
+function notifyVideoRegionSourceReady() {
+ if (typeof videoRegionRenderVersion === 'number') {
+ videoRegionRenderVersion += 1;
+ }
+ slideMarkupCache = Object.create(null);
+ if (typeof showCurrent === 'function' && slides && slides.length) {
+ showCurrent();
+ }
+}
+
+window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
+
// Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide);
diff --git a/src/player/public/sw.js b/src/player/public/sw.js
index 04419ba..490ee19 100644
--- a/src/player/public/sw.js
+++ b/src/player/public/sw.js
@@ -1,4 +1,4 @@
-const CACHE_VERSION = 'v1';
+const CACHE_VERSION = 'v2';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
return new Response('', { status: 504, statusText: 'Offline' });
}
+async function networkOnly(request) {
+ return fetch(request);
+}
+
self.addEventListener('install', function (event) {
self.skipWaiting();
event.waitUntil(Promise.resolve());
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
}
if (url.pathname.startsWith('/media/')) {
- event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
+ event.respondWith(networkOnly(request));
return;
}
diff --git a/src/player/regions/video.js b/src/player/regions/video.js
new file mode 100644
index 0000000..6ab8ba4
--- /dev/null
+++ b/src/player/regions/video.js
@@ -0,0 +1,145 @@
+var videoRegionLastGoodSrcCache = Object.create(null);
+var videoRegionProbeStateCache = Object.create(null);
+var videoRegionProbeTimerCache = Object.create(null);
+var VIDEO_REGION_RETRY_DELAY_MS = 5000;
+
+function getVideoRegionCacheKey(region) {
+ return String(region && (region.regionKey || region.label) || '').trim();
+}
+
+function isDirectlyRenderableSource(src) {
+ return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
+}
+
+function appendCacheBust(src, cacheBust) {
+ var key = String(cacheBust || '').trim();
+ var raw = String(src || '').trim();
+ if (!raw || !key) {
+ return raw;
+ }
+ return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
+}
+
+function getVideoSourceAvailability(src) {
+ return videoRegionProbeStateCache[String(src || '').trim()] || null;
+}
+
+function setVideoSourceAvailability(src, available) {
+ var key = String(src || '').trim();
+ if (!key) {
+ return;
+ }
+ videoRegionProbeStateCache[key] = {
+ available: available === null ? null : Boolean(available),
+ checkedAt: Date.now()
+ };
+}
+
+function logVideoRegionStatus(message, details, level) {
+ if (typeof logDebug === 'function') {
+ logDebug(message, details || '', level || 'info');
+ }
+}
+
+function triggerVideoRegionSourceRefresh() {
+ if (typeof window.notifyVideoRegionSourceReady === 'function') {
+ window.notifyVideoRegionSourceReady();
+ return;
+ }
+
+ if (typeof videoRegionRenderVersion === 'number') {
+ videoRegionRenderVersion += 1;
+ }
+ slideMarkupCache = Object.create(null);
+ if (typeof showCurrent === 'function' && slides && slides.length) {
+ showCurrent();
+ }
+}
+
+function scheduleVideoSourceProbe(regionKey, src, isRetry) {
+ var key = String(src || '').trim();
+ if (!regionKey || !key || isDirectlyRenderableSource(key)) {
+ if (key) {
+ setVideoSourceAvailability(key, true);
+ }
+ return;
+ }
+
+ if (videoRegionProbeTimerCache[key]) {
+ return;
+ }
+
+ if (!isRetry) {
+ logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
+ } else {
+ logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
+ }
+
+ videoRegionProbeTimerCache[key] = window.setTimeout(function () {
+ delete videoRegionProbeTimerCache[key];
+ fetch(key, {
+ method: 'HEAD',
+ cache: 'no-store',
+ credentials: 'same-origin'
+ }).then(function (response) {
+ if (response && response.ok) {
+ setVideoSourceAvailability(key, true);
+ if (videoRegionLastGoodSrcCache[regionKey] !== key) {
+ videoRegionLastGoodSrcCache[regionKey] = key;
+ logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
+ triggerVideoRegionSourceRefresh();
+ }
+ return;
+ }
+
+ setVideoSourceAvailability(key, false);
+ logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
+ scheduleVideoSourceProbe(regionKey, key, true);
+ }).catch(function () {
+ setVideoSourceAvailability(key, false);
+ logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
+ scheduleVideoSourceProbe(regionKey, key, true);
+ });
+ }, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
+}
+
+function renderVideoRegion(region, regionContent) {
+ var requestedSrc = String(regionContent && regionContent.value || '').trim();
+ var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
+ var regionKey = getVideoRegionCacheKey(region);
+ var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
+ var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
+
+ if (!requestedSrc) {
+ return '
';
+ }
+
+ if (isDirectlyRenderableSource(requestedSrc)) {
+ if (regionKey) {
+ videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
+ }
+ setVideoSourceAvailability(requestedSrc, true);
+ return '';
+ }
+
+ var requestedState = getVideoSourceAvailability(requestedSrc);
+ var requestedReady = Boolean(requestedState && requestedState.available === true);
+
+ if (requestedReady) {
+ if (regionKey) {
+ videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
+ }
+ return '';
+ }
+
+ scheduleVideoSourceProbe(regionKey, requestedSrc, false);
+
+ if (cachedSrc) {
+ if (cachedSrc !== requestedSrc) {
+ logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
+ }
+ return '';
+ }
+
+ return '';
+}
\ No newline at end of file
diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js
index 0289311..9803ed1 100644
--- a/src/player/render-helpers.js
+++ b/src/player/render-helpers.js
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'),
+ path.join(__dirname, 'regions', 'video.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
diff --git a/src/player/routes.js b/src/player/routes.js
index 3966a43..0e6c23a 100644
--- a/src/player/routes.js
+++ b/src/player/routes.js
@@ -114,7 +114,7 @@ function registerPlayerRoutes(app, options) {
});
});
- app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) {
+ app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
diff --git a/src/web/lib/slide-thumbnails.js b/src/web/lib/slide-thumbnails.js
index a0f4a5f..48f061e 100644
--- a/src/web/lib/slide-thumbnails.js
+++ b/src/web/lib/slide-thumbnails.js
@@ -98,6 +98,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
: '';
}
+ if (regionType === 'video') {
+ const src = resolveAssetUrl(baseUrl, rawValue);
+ return src
+ ? ''
+ : '';
+ }
+
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
diff --git a/src/web/lib/upload-sync.js b/src/web/lib/upload-sync.js
index a6e77d8..801bc18 100644
--- a/src/web/lib/upload-sync.js
+++ b/src/web/lib/upload-sync.js
@@ -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) {
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index bea5147..a9dc98b 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -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;
}
diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js
index 450d4a9..c8cfe23 100644
--- a/src/web/public/js/playlists/playlist-schedule.js
+++ b/src/web/public/js/playlists/playlist-schedule.js
@@ -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'
+ ? ''
+ : '';
row.innerHTML = '' +
'' +
' ' +
@@ -808,7 +824,11 @@
'' +
'' +
' | ' +
- ' | ' +
+ '' +
+ '' +
+ (values.useVideoDuration ? '' : '') +
+ durationActionMarkup +
+ ' | ' +
'' +
' ' +
' ' +
@@ -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();
}
});
diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js
index 0537077..c4d1fab 100644
--- a/src/web/public/js/slides/slide-form.js
+++ b/src/web/public/js/slides/slide-form.js
@@ -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 ' ';
}
+ 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
- ? '  + ') '
- : ' No image ';
+ ? ' ' +
+ (isVideo
+ ? ' '
+ : '  + ') ') +
+ ' ' +
+ ' '
+ : ' No ' + (isVideo ? 'video' : 'image') + ' ';
+
+ 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 ? '  + ') ' : ' Image ')
+ : region.region_type === 'video'
+ ? (value ? ' ' : ' Video ')
: 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 ' ' + content + ' ';
}).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 '' +
- ' ' +
+ ' ' +
'';
}
+ function renderVideoRegion(region) {
+ var current = getCurrentRegionValue(region);
+ var currentDuration = getCurrentRegionVideoDuration(region);
+ return '' +
+ ' ';
+ }
+
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('') : ' This template has no regions. ';
+ 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)_/, '');
diff --git a/src/web/public/js/templates/template-designer-utils.js b/src/web/public/js/templates/template-designer-utils.js
index cf662e0..60f44be 100644
--- a/src/web/public/js/templates/template-designer-utils.js
+++ b/src/web/public/js/templates/template-designer-utils.js
@@ -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) {
diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js
index 2dfc36e..fb14ef1 100644
--- a/src/web/public/js/templates/template-designer.js
+++ b/src/web/public/js/templates/template-designer.js
@@ -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';
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index af453a6..4545685 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -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');
diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js
index 9e15288..6a22de8 100644
--- a/src/web/routes/admin/manage.js
+++ b/src/web/routes/admin/manage.js
@@ -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 = ?',
diff --git a/src/web/routes/signage/playlists/edit.js b/src/web/routes/signage/playlists/edit.js
index 224ce57..dada636 100644
--- a/src/web/routes/signage/playlists/edit.js
+++ b/src/web/routes/signage/playlists/edit.js
@@ -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']
});
};
diff --git a/src/web/views/settings/tasks-scheduled/index.hbs b/src/web/views/settings/tasks-scheduled/index.hbs
index 8782822..dc94994 100644
--- a/src/web/views/settings/tasks-scheduled/index.hbs
+++ b/src/web/views/settings/tasks-scheduled/index.hbs
@@ -11,7 +11,7 @@
|