Release 1.5.3

This commit is contained in:
2026-07-25 15:11:41 +01:00
parent 9ee938fd2f
commit 0e89892c94
89 changed files with 7660 additions and 542 deletions
+1
View File
@@ -397,6 +397,7 @@ body.screen-blackout #app {
.template-region-rtmp-placeholder {
position: absolute;
inset: 0;
display: flex;
}
.template-region-placeholder {
@@ -89,6 +89,12 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
// Add the fade time to a slide's hold duration so the configured duration remains visible.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
}
// Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() {
if (slideTransitionTimer) {
@@ -1,5 +1,8 @@
// Show or hide the offline status banner.
function setOfflineBannerVisible(visible, message) {
if (window.__pulseThumbnailPreview) {
return;
}
var normalizedVisible = Boolean(visible);
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
if (normalizedVisible) {
@@ -38,6 +41,9 @@ function setOfflineBannerVisible(visible, message) {
// Update the offline banner based on connectivity or playlist availability.
function syncOfflineBanner() {
if (window.__pulseThumbnailPreview) {
return;
}
if (!window.navigator.onLine) {
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
return;
@@ -57,6 +63,9 @@ function clearRefreshRetry() {
// Retry playlist refresh with a short backoff while the player is offline.
function scheduleRefreshRetry() {
if (window.__pulseThumbnailPreview) {
return;
}
if (refreshRetryTimer) {
return;
}
+81 -14
View File
@@ -1,5 +1,5 @@
// Render the slide at the requested index within the active set.
function renderSlideAtIndex(sourceSlides, targetIndex) {
async function renderSlideAtIndex(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
@@ -11,18 +11,52 @@ function renderSlideAtIndex(sourceSlides, targetIndex) {
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
}
const slide = availableSlides[normalizedIndex];
var slideCount = availableSlides.length;
var currentIndex = normalizedIndex;
var slide = null;
var attempts = 0;
var renderRequestId = ++slideRenderRequestId;
while (attempts < slideCount) {
slide = availableSlides[currentIndex];
if (!slide) {
break;
}
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
var isAvailable = await probeRtmpSlideAvailability(slide);
if (!isAvailable) {
if (renderRequestId !== slideRenderRequestId) {
return false;
}
currentIndex = (currentIndex + 1) % slideCount;
attempts += 1;
slide = null;
continue;
}
}
if (renderRequestId !== slideRenderRequestId) {
break;
}
break;
}
if (!slide) {
if (currentPlaylistSkipUnavailableRtmp) {
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
scheduleRefreshRetry();
return false;
}
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
return false;
}
index = normalizedIndex;
index = currentIndex;
var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
sendCommandState(slide);
if (!isPaused) {
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000);
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
}
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true;
@@ -36,6 +70,7 @@ function applyPendingPlaylistUpdate() {
slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
slideMarkupCache = Object.create(null);
@@ -53,18 +88,45 @@ function showCurrent() {
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpPreloads === 'function') {
syncRtmpPreloads(activeSlides, index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(activeSlides, index);
}
if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return;
}
renderSlideAtIndex(activeSlides, index);
void renderSlideAtIndex(activeSlides, index);
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
}
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) {
return false;
}
var silent = Boolean(options && options.silent);
const activeSlides = getCurrentActiveSlides();
if (!activeSlides.length) {
return false;
}
clearSlideTimer();
if (!silent) {
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
}
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
window.setTimeout(function () {
if (!getCurrentActiveSlides().length) {
return;
}
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
}, 0);
return true;
}
// Fetch the latest playlist and queue any updates.
function refresh() {
var request = new XMLHttpRequest();
@@ -85,7 +147,7 @@ function refresh() {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
@@ -105,10 +167,12 @@ function refresh() {
const nextSignature = getPlaylistRevision(data);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
savePlaylistSnapshot({
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp,
etag: responseEtag
});
markRefreshHealthy();
@@ -121,6 +185,7 @@ function refresh() {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
index = 0;
showCurrent();
sendCommandState(lastRenderedSlide);
@@ -128,18 +193,19 @@ function refresh() {
}
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
if (typeof syncRtmpPreloads === 'function') {
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
}
if (currentActiveSlides.length < 2) {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
pendingPlaylistUpdate = null;
index = 0;
showCurrent();
@@ -149,7 +215,8 @@ function refresh() {
pendingPlaylistUpdate = {
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) {
@@ -171,7 +238,7 @@ function refresh() {
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
};
request.ontimeout = function () {
@@ -183,7 +250,7 @@ function refresh() {
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
};
request.send();
+22 -4
View File
@@ -411,6 +411,22 @@ function getTemplateLayout(template) {
return layout;
}
// Build a dark backdrop style for template and media canvases.
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
var color = String(backgroundColor || '#111111').trim() || '#111111';
var style = 'background-color:' + escapeHtml(color) + ';';
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
if (backgroundImagePath) {
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
return style;
}
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
return style;
}
// Build the cache key for a template render plan.
function getTemplateRenderPlanCacheKey(template) {
return getTemplateLayoutCacheKey(template);
@@ -469,7 +485,7 @@ function renderTemplateSlideMarkup(slide) {
const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent);
}).join('') : '';
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
}
@@ -486,14 +502,16 @@ function renderMediaSlideContent(slide) {
function renderMediaSlideMarkup(slide) {
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
const media = renderMediaSlideContent(slide);
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
}
// Render the shared slide shell around slide-specific inner content.
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
}
// Build the cache key for rendered slide markup.