diff --git a/CHANGELOG.md b/CHANGELOG.md index aa19b41..e319948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. - No unreleased changes recorded yet. +## 1.5.15 - 2026-07-26 + +### Changed + +- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary. +- Dashboard action cards now use a three-column grid on wider layouts. + +### Fixed + +- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds. + ## 1.5.14 - 2026-07-26 ### Fixed diff --git a/package-lock.json b/package-lock.json index 4d12d99..0b0d8de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "1.5.14", + "version": "1.5.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "1.5.14", + "version": "1.5.15", "dependencies": { "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", diff --git a/package.json b/package.json index d1c4f8a..949b86b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.14", + "version": "1.5.15", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index cb7c03d..6862dcf 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -89,10 +89,13 @@ function scheduleSlideAdvance(delayMs) { }, holdDelayMs); } -// Add the fade time to a slide's hold duration unless the slide duration already accounts for it. -function getSlideHoldDelay(delayMs, skipFadePadding) { +// Account for fade only when it is enabled so the transition is centered on the slide boundary. +function getSlideHoldDelay(delayMs) { var holdDelayMs = Math.max(1, Number(delayMs || 0)); - return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0); + if (!currentPlaylistFadeBetweenSlides) { + return holdDelayMs; + } + return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2)); } // Cancel any pending fade-transition cleanup. @@ -110,17 +113,20 @@ function renderSlideMarkup(markup, shouldFade) { destroyRtmpRegions(app); } - function initializeRenderedVideoPlayback(root) { + function initializeRenderedVideoPlayback(root, delayMs) { if (!root) { return; } + var startDelayMs = Math.max(0, Number(delayMs || 0)); var videos = root.querySelectorAll('.template-region.video video'); Array.prototype.forEach.call(videos, function (video) { if (!video) { return; } + var playbackScheduled = false; + video.autoplay = true; video.loop = true; video.muted = true; @@ -135,13 +141,29 @@ function renderSlideMarkup(markup, shouldFade) { } } - if (video.readyState >= 2) { + function schedulePlaybackStart() { + if (playbackScheduled) { + return; + } + playbackScheduled = true; + if (startDelayMs > 0) { + window.setTimeout(startPlayback, startDelayMs); + return; + } startPlayback(); + } + + if (video.readyState >= 2) { + schedulePlaybackStart(); return; } - video.addEventListener('canplay', startPlayback, { once: true }); - video.addEventListener('loadedmetadata', startPlayback, { once: true }); + video.addEventListener('canplay', schedulePlaybackStart, { once: true }); + video.addEventListener('loadedmetadata', function () { + if (video.readyState >= 2) { + schedulePlaybackStart(); + } + }, { once: true }); }); } @@ -168,6 +190,28 @@ function renderSlideMarkup(markup, shouldFade) { }); } + function pauseRenderedVideoPlayback(root, delayMs) { + if (!root) { + return; + } + + var pauseDelayMs = Math.max(0, Number(delayMs || 0)); + var videos = root.querySelectorAll('.template-region.video video, .slide-media video'); + Array.prototype.forEach.call(videos, function (video) { + if (!video || typeof video.pause !== 'function') { + return; + } + + window.setTimeout(function () { + try { + video.pause(); + } catch (_error) { + // Ignore pause errors from detached or unsupported media elements. + } + }, pauseDelayMs); + }); + } + var nextShell = document.createElement('div'); nextShell.className = 'slide-shell'; nextShell.style.opacity = '0'; @@ -180,7 +224,7 @@ function renderSlideMarkup(markup, shouldFade) { if (typeof syncRtmpRegions === 'function') { syncRtmpRegions(nextShell); } - initializeRenderedVideoPlayback(nextShell); + initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2); return nextShell; } @@ -188,6 +232,7 @@ function renderSlideMarkup(markup, shouldFade) { previousShell.classList.add('slide-shell'); } previousShell.style.opacity = '1'; + pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2); app.appendChild(nextShell); void nextShell.offsetHeight; @@ -200,7 +245,7 @@ function renderSlideMarkup(markup, shouldFade) { syncRtmpRegions(nextShell); } - initializeRenderedVideoPlayback(nextShell); + initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2); slideTransitionTimer = window.setTimeout(function () { if (previousShell && previousShell.parentNode) { diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 0653930..e1b597e 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -290,7 +290,7 @@ .dashboard-action-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index c4d1fab..97c4476 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -1277,7 +1277,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo var duration = Number(video.duration); cleanup(); if (Number.isFinite(duration) && duration > 0) { - resolve(Math.max(1, Math.round(duration))); + resolve(Math.max(0.001, Math.round(duration * 1000) / 1000)); } else { reject(new Error('The video duration could not be read.')); }