Implement video region support
This commit is contained in:
@@ -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;
|
||||
|
||||
+40
-5
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||
}
|
||||
|
||||
if (isDirectlyRenderableSource(requestedSrc)) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
var requestedReady = Boolean(requestedState && requestedState.available === true);
|
||||
|
||||
if (requestedReady) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
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 '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user