From eb1f33d82fd7c92c53956cbafab1fbd4f316fceb Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 26 Jul 2026 14:54:50 +0100 Subject: [PATCH] Implement video region support --- src/data/admin.js | 2 +- src/data/slides.js | 12 + src/data/templates.js | 2 +- src/db/index.js | 3 +- src/db/migrations.js | 44 +- src/player/player-page.script.html | 1 + src/player/playlist.js | 45 +- src/player/public/css/player.css | 13 + src/player/public/js/player-page-commands.js | 46 +- src/player/public/js/player-page-playback.js | 2 +- src/player/public/js/player-page-rendering.js | 17 +- src/player/public/sw.js | 8 +- src/player/regions/video.js | 145 ++++++ src/player/render-helpers.js | 1 + src/player/routes.js | 2 +- src/web/lib/slide-thumbnails.js | 7 + src/web/lib/upload-sync.js | 10 +- src/web/public/css/theme-custom.css | 40 +- .../public/js/playlists/playlist-schedule.js | 313 +++++++++++- src/web/public/js/slides/slide-form.js | 459 ++++++++++++++---- .../js/templates/template-designer-utils.js | 2 +- .../public/js/templates/template-designer.js | 4 +- src/web/routes/admin/content.js | 65 +++ src/web/routes/admin/manage.js | 19 +- src/web/routes/signage/playlists/edit.js | 94 +++- .../views/settings/tasks-scheduled/index.hbs | 2 +- src/web/views/signage/playlists/edit.hbs | 13 +- src/web/views/signage/slides/form.hbs | 4 +- src/web/views/signage/templates/add.hbs | 1 + src/web/views/signage/templates/edit.hbs | 1 + 30 files changed, 1243 insertions(+), 134 deletions(-) create mode 100644 src/player/regions/video.js 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 '
Video
'; + } + + 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 '
Video
'; +} \ 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 - ? 'Current image preview' - : '
No image
'; + ? '
' + + (isVideo + ? '' + : 'Current image preview') + + '' + + '
' + : '
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 '' + - '
' + + '
' + '
' + '' + escapeHtml(region.label) + '' + 'Image' + @@ -1652,6 +1915,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo '
'; } + function renderVideoRegion(region) { + var current = getCurrentRegionValue(region); + var currentDuration = getCurrentRegionVideoDuration(region); + return '' + + '
' + + '
' + + '' + escapeHtml(region.label) + '' + + 'Video' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + (current + ? '
' + + '' + + '' + + '
' + : '
No video
') + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + } + 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 @@

Scheduled Tasks

-