From 8c0c22156edd7ed5684ddcdef9926f8faf9666d7 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 29 Aug 2026 14:59:06 +0100 Subject: [PATCH] Release v2.10.6 --- CHANGELOG.md | 22 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- .../player-onboarding-landing.script.html | 20 +- src/player/player-page.script.html | 9 +- src/player/public/js/player-page-animation.js | 253 +++++++++++++++++ src/player/public/js/player-page-commands.js | 196 +------------ src/player/public/js/player-page-media.js | 94 ++++++ src/player/public/js/player-page-playback.js | 43 ++- src/player/public/js/player-page-rendering.js | 268 +----------------- .../public/js/player-page-transition.js | 104 +++++++ src/player/public/sw.js | 2 +- src/player/regions/video.js | 8 +- src/player/render-helpers.js | 6 + src/player/render.js | 6 +- src/player/routes.js | 18 +- src/web.js | 3 +- src/web/bootstrap.js | 1 + src/web/lib/media/upload-sync.js | 31 +- src/web/lib/notify-player-screens.js | 22 +- src/web/lib/player-actions.js | 17 ++ src/web/public/css/theme-custom.css | 4 - src/web/public/js/onboarding/pair.js | 39 ++- src/web/routes/onboarding.js | 62 ++-- test/notify-player-screens.test.js | 34 +++ test/player-page-commands-performance.test.js | 14 +- test/player-page-playback.test.js | 123 +++++++- test/upload-sync.test.js | 44 ++- 30 files changed, 922 insertions(+), 531 deletions(-) create mode 100644 src/player/public/js/player-page-animation.js create mode 100644 src/player/public/js/player-page-media.js create mode 100644 src/player/public/js/player-page-transition.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 059afde..eafc9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## 2.10.6 - 2026-08-29 + +### Changed + +- Refactored the player runtime into focused animation, media, transition, command, playback, and rendering modules. +- Improved player slide transitions and video playback by preloading media, preserving precise durations, pausing outgoing videos during crossfades, and deferring expensive post-render setup. +- Added resilient playlist recovery through cached browser and server snapshots, offline status reporting, and refresh handling that recovers after cached responses. + +### Fixed + +- Fixed remote player commands and media synchronization so they route through the bridge using the physical player device identity, including announcement and playlist refresh notifications. +- Fixed player media handling for remote bridge uploads and deletes by using the bridge endpoint with explicit player-device authentication. +- Fixed player service-worker caching so ranged media requests bypass stale cached responses and video playback remains reliable. + +## 2.10.5 - 2026-08-29 + +### Fixed + +- Fixed intermittent pairing failures while player registration and pairing sessions propagate through the bridge by retrying transient resolution and completion requests. +- Fixed the pairing page so transient submission failures retry automatically and pairing progress uses a single spinner without dimming the form. +- Fixed the player onboarding page so it continues polling until the pairing code and QR code are available. + ## 2.10.4 - 2026-08-29 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index a453225..a607638 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.10.4", + "version": "2.10.6", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index e32aaf3..820f4fd 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.10.4", + "version": "2.10.6", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 9664026..a838b43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.10.4", + "version": "2.10.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.10.4", + "version": "2.10.6", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 79c55ea..6539c9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.10.4", + "version": "2.10.6", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index ec5ad9e..8961876 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -44,6 +44,22 @@ }) .catch(function () { return null; }); } + function keepPairingSessionLoaded(deviceId, clientId) { + var pairingSessionPoll = null; + function poll() { + loadPairingSession(deviceId, clientId).then(function (payload) { + if (payload && payload.pairingCode) { + loadQr(deviceId, clientId); + if (pairingSessionPoll) { + window.clearInterval(pairingSessionPoll); + pairingSessionPoll = null; + } + } + }); + } + poll(); + pairingSessionPoll = window.setInterval(poll, 1000); + } function getClientId() { var stored = getSessionStorageItem("pulse-signage-player-client-id"); if (stored) { return stored; } @@ -74,9 +90,7 @@ if (qr && !qr.getAttribute("src")) { qr.src = qrPlaceholderSrc; } - loadPairingSession(deviceId, clientId).then(function () { - loadQr(deviceId, clientId); - }); + keepPairingSessionLoaded(deviceId, clientId); window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000); }); }()); diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index f34fe56..81d23cd 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -42,7 +42,8 @@ let isBlackout = false; let pausedRemainingMs = null; let slideExpiresAt = null; - const slideFadeDurationMs = 560; + const slideFadeLengthMs = 560; + const slideFadeOffsetMs = slideFadeLengthMs / 2; const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug); const commandClientStorageKey = 'pulse-signage-player-client-id'; const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug; @@ -64,10 +65,12 @@ function logDebug(message, details, level) { var logger = level === 'error' ? console.error : console.info; + var timestamp = new Date().toISOString(); + var timestampedMessage = '[' + timestamp + '] ' + String(message || ''); if (details) { - logger(message, details); + logger(timestampedMessage, details); } else { - logger(message); + logger(timestampedMessage); } } diff --git a/src/player/public/js/player-page-animation.js b/src/player/public/js/player-page-animation.js new file mode 100644 index 0000000..b762609 --- /dev/null +++ b/src/player/public/js/player-page-animation.js @@ -0,0 +1,253 @@ +// Region animation configuration and lifecycle helpers. + +function normalizePlayerAnimationConfig(value) { + var raw = value; + if (typeof raw === 'string') { + var text = String(raw || '').trim(); + if (!text) { + raw = null; + } else { + try { + raw = JSON.parse(text); + } catch (_error) { + raw = null; + } + } + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + raw = {}; + } + + return { + intro: normalizePlayerAnimationStep(raw.intro, 'none'), + outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'), + loop: normalizePlayerAnimationStep(raw.loop, 'none') + }; +} + +function normalizePlayerAnimationStep(value, fallbackPreset) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { + preset: String(value.preset || fallbackPreset || 'none').trim(), + duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null, + delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null, + iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null + }; + } + + return { + preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(), + duration_ms: null, + delay_ms: null, + iterations: null + }; +} + +function getAnimationStepTimingMs(step) { + if (!step) { + return 0; + } + + var preset = String(step.preset || 'none').trim(); + if (!preset || preset === 'none') { + return 0; + } + + var durationMs = Number(step.duration_ms); + if (!Number.isFinite(durationMs) || durationMs <= 0) { + durationMs = 1000; + } + + var delayMs = Number(step.delay_ms); + if (!Number.isFinite(delayMs) || delayMs < 0) { + delayMs = 0; + } + + var iterations = Number(step.iterations); + if (!Number.isFinite(iterations) || iterations < 1) { + iterations = 1; + } + + return delayMs + (durationMs * iterations); +} + +function getRegionAnimationPhaseTimings(root, phase) { + if (!root) { + return []; + } + + var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); + if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { + normalizedPhase = 'intro'; + } + + var timings = []; + Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) { + if (!element) { + return; + } + + var config; + try { + config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}'); + } catch (_error) { + config = null; + } + + if (!config) { + return; + } + + var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro); + if (timingMs > 0) { + timings.push({ + element: element, + timingMs: timingMs + }); + } + }); + + return timings; +} + +function getRegionAnimationPhaseTimingMs(root, phase) { + return getRegionAnimationPhaseTimings(root, phase).reduce(function (maxTimingMs, entry) { + return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0)); + }, 0); +} + +function clearRegionAnimationClasses(root) { + if (!root) { + return; + } + + var elements = []; + if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) { + elements.push(root); + } + Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) { + elements.push(element); + }); + + elements.forEach(function (element) { + if (!element) { + return; + } + element.classList.remove('animate__animated', 'animate__infinite'); + element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3'); + Array.prototype.slice.call(element.classList || []).forEach(function (className) { + if (String(className || '').indexOf('animate__') === 0) { + element.classList.remove(className); + } + }); + element.style.removeProperty('--animate-duration'); + element.style.removeProperty('--animate-delay'); + element.style.removeProperty('--animate-repeat'); + }); +} + +function isAttentionSeekerAnimation(preset) { + return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1; +} + +function applyAnimationStep(element, step, phase) { + if (!element || !step) { + return; + } + + var preset = String(step.preset || 'none').trim(); + if (!preset || preset === 'none') { + return; + } + + element.classList.add('animate__animated', 'animate__' + preset); + element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms'); + if (Number(step.delay_ms || 0) > 0) { + element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms'); + } else { + element.style.removeProperty('--animate-delay'); + } + + if (phase === 'loop') { + var repeatCount = Number(step.iterations); + if (!Number.isFinite(repeatCount) || repeatCount < 1) { + repeatCount = 1; + } + if (repeatCount > 1) { + element.classList.add('animate__repeat-1'); + } + element.style.setProperty('--animate-repeat', String(repeatCount)); + return; + } + + if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) { + element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1)))); + } +} + +function playRegionAnimation(element, phase) { + if (!element) { + return; + } + + var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); + if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { + normalizedPhase = 'intro'; + } + + var config; + try { + config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}'); + } catch (_error) { + config = null; + } + + if (!config) { + return; + } + + element.dataset.animationPhase = normalizedPhase; + clearRegionAnimationClasses(element); + + if (normalizedPhase === 'outro') { + applyAnimationStep(element, config.outro, 'outro'); + return; + } + + if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') { + applyAnimationStep(element, config.intro, 'intro'); + if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') { + element.addEventListener('animationend', function handleAnimationEnd(event) { + if (event.target !== element) { + return; + } + if (String(element.dataset.animationPhase || '').trim() !== 'intro') { + return; + } + element.removeEventListener('animationend', handleAnimationEnd); + clearRegionAnimationClasses(element); + applyAnimationStep(element, config.loop, 'loop'); + }); + } + return; + } + + applyAnimationStep(element, config.loop, 'loop'); +} + +function playRegionAnimations(root, phase) { + if (!root) { + return; + } + + var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); + if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { + normalizedPhase = 'intro'; + } + + var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]')); + elements.forEach(function (element) { + playRegionAnimation(element, normalizedPhase); + }); +} diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index e76f78e..f6bf79b 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -6,7 +6,6 @@ function getCurrentViewport() { }; } -var slideOutroTimers = []; var commandHeartbeatTimer = null; // Command websocket and player-state helpers. @@ -62,103 +61,6 @@ function scheduleViewportRenderUpdate() { }, 150); } -// Cancel the current slide-advance timer. -function clearSlideTimer() { - if (timer) { - window.clearTimeout(timer); - timer = null; - } - clearSlideOutroTimer(); -} - -// Cancel any pending outro triggers for the current slide. -function clearSlideOutroTimer() { - if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) { - slideOutroTimers = []; - return; - } - - slideOutroTimers.forEach(function (timerId) { - window.clearTimeout(timerId); - }); - slideOutroTimers = []; -} - -// Return the rendered slide root that is currently on screen. -function getCurrentSlideRoot() { - var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []); - if (shells.length) { - return shells[shells.length - 1]; - } - return app && app.firstElementChild ? app.firstElementChild : app; -} - -// Schedule the outgoing slide animation for each region so it finishes before removal. -function scheduleSlideOutro(holdDelayMs) { - clearSlideOutroTimer(); - - var currentRoot = getCurrentSlideRoot(); - if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') { - return; - } - - var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro'); - if (!regionTimings.length) { - return; - } - - var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0))); - slideOutroTimers = regionTimings.map(function (entry) { - var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0))); - var triggerDelayMs = Math.max(0, slideDurationMs - timingMs); - return window.setTimeout(function () { - if (!entry || !entry.element || !entry.element.isConnected) { - return; - } - playRegionAnimation(entry.element, 'outro'); - }, triggerDelayMs); - }); -} - -// Schedule the next slide transition. -function scheduleSlideAdvance(delayMs) { - clearSlideTimer(); - var holdDelayMs = Math.max(1, Number(delayMs || 0)); - slideExpiresAt = Date.now() + holdDelayMs; - scheduleSlideOutro(holdDelayMs); - timer = window.setTimeout(function () { - timer = null; - slideExpiresAt = null; - pausedRemainingMs = null; - clearSlideOutroTimer(); - applyPendingPlaylistUpdate(); - const activeSlides = getCurrentActiveSlides(); - if (activeSlides.length < 2) { - showCurrent(); - return; - } - if (index >= activeSlides.length) { - index = 0; - } - index = (index + 1) % activeSlides.length; - showCurrent(); - }, holdDelayMs); -} - -// Return the slide duration without shifting it for fade timing. -function getSlideHoldDelay(delayMs) { - var holdDelayMs = Math.max(1, Number(delayMs || 0)); - return holdDelayMs; -} - -// Cancel any pending fade-transition cleanup. -function clearSlideTransitionTimer() { - if (slideTransitionTimer) { - window.clearTimeout(slideTransitionTimer); - slideTransitionTimer = null; - } -} - function getPlayerRegionModules() { if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') { return []; @@ -195,65 +97,11 @@ function initializeRegionInstances(root) { } // Swap slide markup with optional fade animation. -function renderSlideMarkup(markup, shouldFade) { +function renderSlideMarkup(markup, shouldFade, mediaDelayMs) { clearSlideTransitionTimer(); destroyRegionInstances(app); - if (typeof destroyRtmpRegions === 'function') { - destroyRtmpRegions(app); - } - - 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 = !(video.dataset && video.dataset.disableAudio === '0'); - video.playsInline = true; - - function startPlayback() { - var playPromise = video.play && video.play(); - if (playPromise && typeof playPromise.catch === 'function') { - playPromise.catch(function () { - return null; - }); - } - } - - 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', schedulePlaybackStart, { once: true }); - video.addEventListener('loadedmetadata', function () { - if (video.readyState >= 2) { - schedulePlaybackStart(); - } - }, { once: true }); - }); + if (typeof destroySlideMedia === 'function') { + destroySlideMedia(app); } function schedulePostRenderSetup(root, delayMs) { @@ -265,13 +113,10 @@ function renderSlideMarkup(markup, shouldFade) { return; } - if (typeof syncRtmpRegions === 'function') { - syncRtmpRegions(root); - } - initializeRegionInstances(root); - - initializeRenderedVideoPlayback(root, delayMs); + if (typeof initializeSlideMedia === 'function') { + initializeSlideMedia(root, delayMs); + } if (typeof playRegionAnimations === 'function') { playRegionAnimations(root, 'intro'); @@ -298,28 +143,6 @@ 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 slide-shell-entering'; nextShell.style.zIndex = '0'; @@ -332,7 +155,7 @@ function renderSlideMarkup(markup, shouldFade) { nextShell.classList.remove('slide-shell-entering'); nextShell.classList.add('is-visible'); app.appendChild(nextShell); - schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2); + schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs); return nextShell; } @@ -343,7 +166,6 @@ function renderSlideMarkup(markup, shouldFade) { previousShell.classList.add('is-exiting'); previousShell.style.zIndex = '1'; previousShell.style.opacity = '1'; - pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2); app.appendChild(nextShell); window.requestAnimationFrame(function () { @@ -351,7 +173,7 @@ function renderSlideMarkup(markup, shouldFade) { previousShell.style.opacity = '0'; nextShell.classList.remove('slide-shell-entering'); nextShell.classList.add('is-visible'); - schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2); + schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs); }); slideTransitionTimer = window.setTimeout(function () { @@ -365,7 +187,7 @@ function renderSlideMarkup(markup, shouldFade) { nextShell.classList.add('is-visible'); } slideTransitionTimer = null; - }, slideFadeDurationMs); + }, slideFadeLengthMs); return nextShell; } diff --git a/src/player/public/js/player-page-media.js b/src/player/public/js/player-page-media.js new file mode 100644 index 0000000..bc2921a --- /dev/null +++ b/src/player/public/js/player-page-media.js @@ -0,0 +1,94 @@ +// Slide media startup and teardown helpers. + +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 = false; + video.loop = !(video.dataset && video.dataset.loop === '0'); + video.muted = !(video.dataset && video.dataset.disableAudio === '0'); + video.playsInline = true; + + function startPlayback() { + var playPromise = video.play && video.play(); + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(function () { + return null; + }); + } + } + + 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', schedulePlaybackStart, { once: true }); + video.addEventListener('loadedmetadata', function () { + if (video.readyState >= 2) { + schedulePlaybackStart(); + } + }, { once: true }); + }); +} + +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) { + return null; + } + }, pauseDelayMs); + }); +} + +function initializeSlideMedia(root, delayMs) { + if (!root || root.isConnected === false) { + return; + } + + if (typeof syncRtmpRegions === 'function') { + syncRtmpRegions(root); + } + initializeRenderedVideoPlayback(root, delayMs); +} + +function destroySlideMedia(root) { + if (typeof destroyRtmpRegions === 'function') { + destroyRtmpRegions(root); + } +} diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index 54bcaa3..84a69b2 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -53,18 +53,34 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) { index = currentIndex; var markup = buildSlideMarkup(slide); - renderSlideMarkup(markup, !(options && options.skipFade) && currentPlaylistFadeBetweenSlides); + var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides; + var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined; + renderSlideMarkup(markup, shouldFade, mediaDelayMs); if (typeof scheduleSlideMarkupPreload === 'function') { scheduleSlideMarkupPreload(availableSlides, currentIndex); } sendCommandState(slide); if (!isPaused) { - scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000)); + scheduleSlideAdvance(getSlideAdvanceDelay(slide)); } lastRenderedViewKey = getCurrentRenderKey(availableSlides); return true; } +// Calculate the configured time from slide entry to the next transition. +function getSlideAdvanceDelay(slide) { + var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000; + if (slide && slide.use_video_duration) { + return currentPlaylistFadeBetweenSlides + ? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs)) + : getSlideHoldDelay(durationMs); + } + if (currentPlaylistFadeBetweenSlides) { + return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs)); + } + return getSlideHoldDelay(durationMs); +} + // Promote a deferred playlist update at the next safe point. function applyPendingPlaylistUpdate() { if (!pendingPlaylistUpdate) { @@ -167,11 +183,16 @@ function refresh(applyImmediately) { return; } if (request.status === 304) { + if (!initialData || !Array.isArray(initialData.apiSources) || !Array.isArray(initialData.weatherLocations)) { + currentPlaylistEtag = ''; + refresh(applyImmediately); + return; + } logDebug('Playlist refresh completed with no changes.'); markRefreshHealthy(); setOfflineBannerVisible(false); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); + scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide)); } return; } @@ -198,6 +219,7 @@ function refresh(applyImmediately) { const nextActiveSlides = getActiveSlidesFrom(nextSlides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); + const hadSourceData = !(typeof window !== 'undefined' && window.initialData === null); var refreshedInitialData = Object.assign({}, typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), { screen: data.screen || null, @@ -213,6 +235,11 @@ function refresh(applyImmediately) { initialData = refreshedInitialData; } window.initialData = refreshedInitialData; + if (!hadSourceData) { + slideMarkupCache = Object.create(null); + templateLayoutCache = Object.create(null); + templateRenderPlanCache = Object.create(null); + } savePlaylistSnapshot({ slides: nextSlides, signature: nextSignature, @@ -237,8 +264,12 @@ function refresh(applyImmediately) { return; } if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) { + if (!hadSourceData) { + showCurrent({ skipFade: true }); + return; + } if (currentActiveSlides.length < 2 && lastRenderedSlide) { - scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); + scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide)); } return; } @@ -300,7 +331,7 @@ function refresh(applyImmediately) { setOfflineBannerVisible(true); scheduleRefreshRetry(); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); + scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide)); } }; request.ontimeout = function () { @@ -312,7 +343,7 @@ function refresh(applyImmediately) { setOfflineBannerVisible(true); scheduleRefreshRetry(); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); + scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide)); } }; request.send(); diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 3813e6b..1e5bd9a 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -12,12 +12,10 @@ function normalizeStyleAttributeValue(value) { .replace(/&#39;/g, "'"); } -// Clamp font size to the supported range. function sanitizeFontSize(value) { return Math.max(8, Number(value || 0) || 24); } -// Validate a text color and fall back when needed. function sanitizeTextColor(value, fallback) { var raw = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { @@ -26,7 +24,6 @@ function sanitizeTextColor(value, fallback) { return fallback || '#000000'; } -// Read the template's canvas dimensions with safe defaults. function getTemplateCanvasSize(template) { return { width: Math.max(1, Number(template.canvas_size_width || 1920)), @@ -34,7 +31,6 @@ function getTemplateCanvasSize(template) { }; } -// Read the server-supplied playlist revision, or fall back to the ETag. function getPlaylistRevision(data) { if (data && data.revision) { return String(data.revision); @@ -48,7 +44,6 @@ function getPlaylistRevision(data) { return String(Date.now()); } -// Scale a canvas to fit within the viewport. function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) { var width = Math.max(1, Number(canvasWidth || 0) || 1920); var height = Math.max(1, Number(canvasHeight || 0) || 1080); @@ -61,53 +56,10 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) { }; } -function normalizePlayerAnimationStep(value, fallbackPreset) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return { - preset: String(value.preset || fallbackPreset || 'none').trim(), - duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null, - delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null, - iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null - }; - } - - return { - preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(), - duration_ms: null, - delay_ms: null, - iterations: null - }; -} - -function normalizePlayerAnimationConfig(value) { - var raw = value; - if (typeof raw === 'string') { - var text = String(raw || '').trim(); - if (!text) { - raw = null; - } else { - try { - raw = JSON.parse(text); - } catch (_error) { - raw = null; - } - } - } - - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - raw = {}; - } - - return { - intro: normalizePlayerAnimationStep(raw.intro, 'none'), - outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'), - loop: normalizePlayerAnimationStep(raw.loop, 'none') - }; -} - function hasPlayerAnimation(config) { return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) { - return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none'; + var preset = String((config[stepName] && config[stepName].preset) || '').trim(); + return preset && preset !== 'none'; })); } @@ -122,215 +74,6 @@ function decorateRegionMarkup(markup, region) { }); } -function clearRegionAnimationClasses(root) { - if (!root) { - return; - } - - var elements = []; - if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) { - elements.push(root); - } - Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) { - elements.push(element); - }); - - elements.forEach(function (element) { - if (!element) { - return; - } - element.classList.remove('animate__animated', 'animate__infinite'); - element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3'); - Array.prototype.slice.call(element.classList || []).forEach(function (className) { - if (String(className || '').indexOf('animate__') === 0) { - element.classList.remove(className); - } - }); - element.style.removeProperty('--animate-duration'); - element.style.removeProperty('--animate-delay'); - element.style.removeProperty('--animate-repeat'); - }); -} - -function isAttentionSeekerAnimation(preset) { - return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1; -} - -function applyAnimationStep(element, step, phase) { - if (!element || !step) { - return; - } - - var preset = String(step.preset || 'none').trim(); - if (!preset || preset === 'none') { - return; - } - - element.classList.add('animate__animated', 'animate__' + preset); - element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms'); - if (Number(step.delay_ms || 0) > 0) { - element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms'); - } else { - element.style.removeProperty('--animate-delay'); - } - - if (phase === 'loop') { - var repeatCount = Number(step.iterations); - if (!Number.isFinite(repeatCount) || repeatCount < 1) { - repeatCount = 1; - } - if (repeatCount > 1) { - element.classList.add('animate__repeat-1'); - } - element.style.setProperty('--animate-repeat', String(repeatCount)); - return; - } - - if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) { - element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1)))); - } -} - -function getAnimationStepTimingMs(step) { - if (!step) { - return 0; - } - - var preset = String(step.preset || 'none').trim(); - if (!preset || preset === 'none') { - return 0; - } - - var durationMs = Number(step.duration_ms); - if (!Number.isFinite(durationMs) || durationMs <= 0) { - durationMs = 1000; - } - - var delayMs = Number(step.delay_ms); - if (!Number.isFinite(delayMs) || delayMs < 0) { - delayMs = 0; - } - - var iterations = Number(step.iterations); - if (!Number.isFinite(iterations) || iterations < 1) { - iterations = 1; - } - - return delayMs + (durationMs * iterations); -} - -function getRegionAnimationPhaseTimingMs(root, phase) { - var timings = getRegionAnimationPhaseTimings(root, phase); - return timings.reduce(function (maxTimingMs, entry) { - return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0)); - }, 0); -} - -function getRegionAnimationPhaseTimings(root, phase) { - if (!root) { - return []; - } - - var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); - if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { - normalizedPhase = 'intro'; - } - - var timings = []; - Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) { - if (!element) { - return; - } - - var config; - try { - config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}'); - } catch (_error) { - config = null; - } - - if (!config) { - return; - } - - var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro); - if (timingMs > 0) { - timings.push({ - element: element, - timingMs: timingMs - }); - } - }); - - return timings; -} - -function playRegionAnimation(element, phase) { - if (!element) { - return; - } - - var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); - if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { - normalizedPhase = 'intro'; - } - - var config; - try { - config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}'); - } catch (_error) { - config = null; - } - - if (!config) { - return; - } - - element.dataset.animationPhase = normalizedPhase; - clearRegionAnimationClasses(element); - - if (normalizedPhase === 'outro') { - applyAnimationStep(element, config.outro, 'outro'); - return; - } - - if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') { - applyAnimationStep(element, config.intro, 'intro'); - if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') { - element.addEventListener('animationend', function handleAnimationEnd(event) { - if (event.target !== element) { - return; - } - if (String(element.dataset.animationPhase || '').trim() !== 'intro') { - return; - } - element.removeEventListener('animationend', handleAnimationEnd); - clearRegionAnimationClasses(element); - applyAnimationStep(element, config.loop, 'loop'); - }); - } - return; - } - - applyAnimationStep(element, config.loop, 'loop'); -} - -function playRegionAnimations(root, phase) { - if (!root) { - return; - } - - var normalizedPhase = String(phase || 'intro').trim().toLowerCase(); - if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') { - normalizedPhase = 'intro'; - } - - var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]')); - elements.forEach(function (element) { - playRegionAnimation(element, normalizedPhase); - }); -} - function setPlayerCanvasDimensions(canvasWidth, canvasHeight) { if (!document || !document.documentElement) { return; @@ -600,6 +343,13 @@ function normalizeSlide(slide) { Object.keys(content).forEach(function (regionKey) { normalized.content[regionKey] = normalizeContentValue(content[regionKey]); }); + if (normalized.use_video_duration && normalized.template && Array.isArray(normalized.template.regions)) { + normalized.template.regions.forEach(function (region) { + if (region && region.region_type === 'video' && normalized.content[region.region_key]) { + normalized.content[region.region_key].loop = false; + } + }); + } return normalized; } diff --git a/src/player/public/js/player-page-transition.js b/src/player/public/js/player-page-transition.js new file mode 100644 index 0000000..52ce3d8 --- /dev/null +++ b/src/player/public/js/player-page-transition.js @@ -0,0 +1,104 @@ +// Slide transition timing, cancellation, and outgoing animation coordination. + +var slideOutroTimers = []; + +// Cancel the current slide-advance timer. +function clearSlideTimer() { + if (timer) { + window.clearTimeout(timer); + timer = null; + } + clearSlideOutroTimer(); +} + +// Cancel any pending outro triggers for the current slide. +function clearSlideOutroTimer() { + if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) { + slideOutroTimers = []; + return; + } + + slideOutroTimers.forEach(function (timerId) { + window.clearTimeout(timerId); + }); + slideOutroTimers = []; +} + +// Return the rendered slide root that is currently on screen. +function getCurrentSlideRoot() { + var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []); + if (shells.length) { + return shells[shells.length - 1]; + } + return app && app.firstElementChild ? app.firstElementChild : app; +} + +// Schedule the outgoing slide animation for each region so it finishes before removal. +function scheduleSlideOutro(holdDelayMs) { + clearSlideOutroTimer(); + + var currentRoot = getCurrentSlideRoot(); + if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') { + return; + } + + var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro'); + if (!regionTimings.length) { + return; + } + + var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0))); + slideOutroTimers = regionTimings.map(function (entry) { + var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0))); + var triggerDelayMs = Math.max(0, slideDurationMs - timingMs); + return window.setTimeout(function () { + if (!entry || !entry.element || !entry.element.isConnected) { + return; + } + playRegionAnimation(entry.element, 'outro'); + }, triggerDelayMs); + }); +} + +// Schedule the next slide transition. +function scheduleSlideAdvance(delayMs) { + clearSlideTimer(); + var holdDelayMs = Math.max(1, Number(delayMs || 0)); + slideExpiresAt = Date.now() + holdDelayMs; + scheduleSlideOutro(holdDelayMs); + timer = window.setTimeout(function () { + timer = null; + slideExpiresAt = null; + pausedRemainingMs = null; + clearSlideOutroTimer(); + applyPendingPlaylistUpdate(); + const activeSlides = getCurrentActiveSlides(); + if (activeSlides.length < 2) { + showCurrent(); + return; + } + if (index >= activeSlides.length) { + index = 0; + } + index = (index + 1) % activeSlides.length; + showCurrent(); + }, holdDelayMs); +} + +// Return the configured slide duration without shifting it for fade timing. +function getSlideHoldDelay(delayMs) { + return Math.max(1, Number(delayMs || 0)); +} + +// Calculate the configured time from slide entry to the next transition. +function getSlideAdvanceDelay(slide) { + return getSlideHoldDelay(Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000); +} + +// Cancel any pending fade-transition cleanup. +function clearSlideTransitionTimer() { + if (slideTransitionTimer) { + window.clearTimeout(slideTransitionTimer); + slideTransitionTimer = null; + } +} diff --git a/src/player/public/sw.js b/src/player/public/sw.js index 95a7f5c..b1af83a 100644 --- a/src/player/public/sw.js +++ b/src/player/public/sw.js @@ -1,6 +1,6 @@ // Service worker cache strategy for player pages, assets, media, and playlists. -const CACHE_VERSION = 'v38'; +const CACHE_VERSION = new URL(self.location.href).searchParams.get('v') || 'development'; 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}`; diff --git a/src/player/regions/video.js b/src/player/regions/video.js index 20d4fd9..6cba3cc 100644 --- a/src/player/regions/video.js +++ b/src/player/regions/video.js @@ -112,6 +112,8 @@ function renderVideoRegion(region, regionContent) { var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : ''; var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust); var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio); + var shouldLoop = !(regionContent && regionContent.loop === false); + var loopMarkup = shouldLoop ? ' loop' : ''; if (!requestedSrc) { return ''; @@ -122,7 +124,7 @@ function renderVideoRegion(region, regionContent) { videoRegionLastGoodSrcCache[regionKey] = requestedSrc; } setVideoSourceAvailability(requestedSrc, true); - return '
'; + return '
'; } var requestedState = getVideoSourceAvailability(requestedSrc); @@ -132,7 +134,7 @@ function renderVideoRegion(region, regionContent) { if (regionKey) { videoRegionLastGoodSrcCache[regionKey] = requestedSrc; } - return '
'; + return '
'; } scheduleVideoSourceProbe(regionKey, requestedSrc, false); @@ -141,7 +143,7 @@ function renderVideoRegion(region, regionContent) { if (cachedSrc !== requestedSrc) { logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc); } - return '
'; + return '
'; } return ''; diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js index a9fdc6f..7bf5acb 100644 --- a/src/player/render-helpers.js +++ b/src/player/render-helpers.js @@ -347,6 +347,9 @@ const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html') const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html'); const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js'); const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js'); +const playerPageAnimationScriptPath = path.join(__dirname, 'public', 'js', 'player-page-animation.js'); +const playerPageMediaScriptPath = path.join(__dirname, 'public', 'js', 'player-page-media.js'); +const playerPageTransitionScriptPath = path.join(__dirname, 'public', 'js', 'player-page-transition.js'); const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js'); const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js'); const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js'); @@ -540,6 +543,9 @@ function getPlayerRuntimeScripts() { ['client-name', getScriptBody(getPlayerClientNameScript()())], ['offline', getScriptBody(getPlayerPageOfflineScript()())], ['playlist', getScriptBody(getPlayerPagePlaylistScript()())], + ['animation', fs.readFileSync(playerPageAnimationScriptPath, 'utf8').trim()], + ['media', fs.readFileSync(playerPageMediaScriptPath, 'utf8').trim()], + ['transition', fs.readFileSync(playerPageTransitionScriptPath, 'utf8').trim()], ['commands', getScriptBody(getPlayerPageCommandsScript()())], ['rendering', getScriptBody(getPlayerPageRenderingScript()())], ['playback', getScriptBody(getPlayerPagePlaybackScript()())], diff --git a/src/player/render.js b/src/player/render.js index a7470a3..1c627b1 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -2,6 +2,7 @@ const Handlebars = require('handlebars'); const path = require('path'); +const packageMetadata = require('../../package.json'); const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers'); const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); @@ -22,11 +23,12 @@ function renderPage(template, options) { } function getPlayerServiceWorkerRegistrationScript() { + const releaseVersion = encodeURIComponent(String(packageMetadata.version || 'development')); return [ ''; + return ''; }).join(''); return renderPage(template, { diff --git a/src/player/routes.js b/src/player/routes.js index df255af..a4b2391 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -83,7 +83,7 @@ function registerPlayerRoutes(app, options) { if (requestHeaders['x-pulse-page-auth']) { headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim(); } - if (requestHeaders['if-none-match']) { + if (requestHeaders['if-none-match'] && !requestOptions.skipIfNoneMatch) { headers['if-none-match'] = String(requestHeaders['if-none-match']).trim(); } if (requestHeaders['x-pulse-client-id']) { @@ -495,7 +495,21 @@ function registerPlayerRoutes(app, options) { res.set('Cache-Control', cacheControl); } if (response.status === 304) { - return res.end(); + const cachedSnapshot = await readPlaylistSnapshot(req.params.slug); + if (cachedSnapshot) { + return res.end(); + } + + const refreshedResponse = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { + method: 'GET', + skipIfNoneMatch: true + }); + const refreshedData = await readJsonResponse(refreshedResponse); + if (!refreshedResponse || refreshedResponse.status >= 400 || !refreshedData) { + return res.status(502).json({ error: 'Thin client playlist cache unavailable.' }); + } + await writePlaylistSnapshot(req.params.slug, refreshedData); + return res.json(refreshedData); } res.type(response.headers.get('content-type') || 'application/json'); const responseText = await response.text(); diff --git a/src/web.js b/src/web.js index 64e1458..42a1bab 100644 --- a/src/web.js +++ b/src/web.js @@ -71,7 +71,8 @@ async function start() { playerActionService.forwardPlayerCommand, playerActionService.getScreenConnections, playerActionService.forwardPlayerCommandToBaseUrl, - playerActionService.resolvePlayerBaseUrlForPublicUrl + playerActionService.resolvePlayerBaseUrlForPublicUrl, + playerActionService.forwardPlayerCommandToDevice ); // Centralized dashboard/player bootstrap. diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index f5e4b69..443a573 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -121,6 +121,7 @@ function createWebBootstrap(options) { pool: pool, common: common, playerInternalBaseUrl: configuredPlayerInternalUrl, + bridgeInternalBaseUrl: configuredBridgeInternalUrl, playerSnapshotCache: playerSnapshotCache, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index e0d3425..a86cce4 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -36,22 +36,6 @@ function normalizeBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } -function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) { - const targetBaseUrl = normalizeBaseUrl(baseUrl); - const deviceId = String(playerIdentifier || '').trim(); - if (!targetBaseUrl || !deviceId) { - return targetBaseUrl; - } - - try { - const url = new URL(targetBaseUrl); - url.searchParams.set('deviceId', deviceId); - return url.toString().replace(/\/$/, ''); - } catch (_error) { - return targetBaseUrl; - } -} - function normalizePlayerRowBaseUrl(player) { return normalizeBaseUrl(player && player.internal_base_url); } @@ -68,6 +52,7 @@ function createUploadSyncService(options) { const pool = options && options.pool; const common = options && options.common; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, ''); const playerSnapshotCache = options && options.playerSnapshotCache; const notifyPlayerScreens = options && options.notifyPlayerScreens; const backgroundTaskQueue = options && options.backgroundTaskQueue; @@ -545,6 +530,9 @@ function createUploadSyncService(options) { } const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); + const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl + ? configuredBridgeInternalBaseUrl + : targetBaseUrl; const relativePath = getUploadRelativePath(uploadPath); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); if (!relativePath || !sourcePath) { @@ -566,11 +554,13 @@ function createUploadSyncService(options) { pathname: `/api/media/${encodeURIComponent(relativePath)}`, body: fileBuffer }); - const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier); + const mediaUploadUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`; + const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim(); const response = await fetch(mediaUploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', + ...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}), ...authHeaders }, body: fileBuffer @@ -601,6 +591,9 @@ function createUploadSyncService(options) { } const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); + const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl + ? configuredBridgeInternalBaseUrl + : targetBaseUrl; const relativePath = getUploadRelativePath(uploadPath); if (!relativePath) { return false; @@ -610,11 +603,13 @@ function createUploadSyncService(options) { method: 'DELETE', pathname: `/api/media/${encodeURIComponent(relativePath)}` }); - const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier); + const mediaDeleteUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`; + const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim(); const response = await fetch(mediaDeleteUrl, { method: 'DELETE', headers: { Accept: 'application/json', + ...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}), ...authHeaders } }); diff --git a/src/web/lib/notify-player-screens.js b/src/web/lib/notify-player-screens.js index a2c5f9c..1bc021e 100644 --- a/src/web/lib/notify-player-screens.js +++ b/src/web/lib/notify-player-screens.js @@ -1,6 +1,6 @@ // Route screen refresh commands to the physical player hosting each screen. -function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) { +function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl, forwardPlayerCommandToDevice) { if (typeof forwardPlayerCommand !== 'function') { throw new Error('createNotifyPlayerScreens requires a player command sender.'); } @@ -19,17 +19,35 @@ function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, f if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') { const screenState = await getScreenConnections(slug); const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : []; + const deviceIds = Array.from(new Set(connections.map(function (connection) { + return String(connection && connection.playerDeviceId || '').trim(); + }).filter(Boolean))); const playerBaseUrls = Array.from(new Set(connections.map(function (connection) { + if (String(connection && connection.playerDeviceId || '').trim()) { + return ''; + } return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); }).filter(Boolean))); + const deviceResults = deviceIds.length && typeof forwardPlayerCommandToDevice === 'function' + ? await Promise.all(deviceIds.map(function (deviceId) { + return forwardPlayerCommandToDevice(deviceId, { + command: commandOrPayload || 'refresh', + screenSlug: slug + }); + })) + : []; + let baseUrlResults = []; if (playerBaseUrls.length) { const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function' ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); })) : playerBaseUrls; - return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) { + baseUrlResults = await Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) { return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh'); })); } + if (deviceResults.length || baseUrlResults.length) { + return deviceResults.concat(baseUrlResults); + } } return forwardPlayerCommand(slug, commandOrPayload || 'refresh'); })).then(function (results) { diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index eb8d510..81df924 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -243,6 +243,23 @@ function createPlayerActionService(options) { } async function forwardAnnouncementRefresh(slug) { + try { + const screenState = await getScreenConnections(slug); + const deviceIds = Array.from(new Set((screenState && Array.isArray(screenState.connections) ? screenState.connections : []).map(function (connection) { + return String(connection && connection.playerDeviceId || '').trim(); + }).filter(Boolean))); + if (deviceIds.length) { + const deviceResults = await Promise.all(deviceIds.map(function (deviceId) { + return forwardPlayerCommandToDevice(deviceId, { + command: 'announcement-refresh', + screenSlug: slug + }); + })); + return deviceResults; + } + } catch (_error) { + } + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const targetBaseUrls = Array.from(new Set([ resolvedPlayerInternalBaseUrl, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 88deaa3..5d9ea97 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -901,10 +901,6 @@ padding: 0.75rem 1rem; } -.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) { - opacity: 0.55; -} - .onboarding-pairing-progress { display: flex; align-items: center; diff --git a/src/web/public/js/onboarding/pair.js b/src/web/public/js/onboarding/pair.js index 63db5e7..054b59c 100644 --- a/src/web/public/js/onboarding/pair.js +++ b/src/web/public/js/onboarding/pair.js @@ -44,6 +44,27 @@ lines.push(new Date().toISOString().slice(11, 19) + ' ' + value); scannerDebugOutput.textContent = lines.slice(-8).join('\n'); } + function wait(milliseconds) { + return new Promise(function (resolve) { window.setTimeout(resolve, milliseconds); }); + } + function submitPairing(body, attempt) { + return fetch(form.action, { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body + }).then(function (response) { + return response.text().then(function (text) { + var payload = null; + try { payload = JSON.parse(text); } catch (_error) {} + var transientCodeError = response.status === 401 && payload && String(payload.error || '').indexOf('valid kiosk pairing code') !== -1; + var transientTransportError = [502, 503, 504].indexOf(response.status) !== -1; + if ((!response.ok && (transientCodeError || transientTransportError)) && attempt < 2) { + return wait(300 * (attempt + 1)).then(function () { return submitPairing(body, attempt + 1); }); + } + return { response: response, payload: payload }; + }); + }); + } var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : []; function normalizeCode(value) { @@ -321,28 +342,21 @@ if (firstEmptyInput) { firstEmptyInput.focus(); } return; } - message.className = 'alert alert-info'; - message.textContent = 'Pairing player...'; + message.className = 'alert d-none'; + message.textContent = ''; var pairingBody = new URLSearchParams(new FormData(form)).toString(); if (pairingCard) { pairingCard.classList.add('is-pairing'); } if (pairingProgress) { pairingProgress.classList.remove('d-none'); } Array.prototype.slice.call(form.elements).forEach(function (element) { if (element !== anotherButton && element !== manualButton) { element.disabled = true; } }); - fetch(form.action, { - method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, - body: pairingBody - }).then(function (response) { - return response.text().then(function (text) { - var payload = null; - try { payload = JSON.parse(text); } catch (_error) {} + submitPairing(pairingBody, 0).then(function (result) { + var response = result.response; + var payload = result.payload; if (!response.ok) { throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.'); } if (payload && payload.queued) { - message.className = 'alert alert-info'; - message.textContent = 'Pairing is still being completed. Keep this page open and wait for confirmation.'; return; } message.className = 'alert alert-success'; @@ -363,7 +377,6 @@ } if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); } if (manualButton) { manualButton.classList.remove('d-none'); } - }); }).catch(function (error) { if (pairingCard) { pairingCard.classList.remove('is-pairing'); } if (pairingProgress) { pairingProgress.classList.add('d-none'); } diff --git a/src/web/routes/onboarding.js b/src/web/routes/onboarding.js index b600393..27ad6f4 100644 --- a/src/web/routes/onboarding.js +++ b/src/web/routes/onboarding.js @@ -4,6 +4,12 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); const { fetchPlayerRegistrations } = require('#src/data/player-registry'); const { renderView } = require('../view'); +function wait(milliseconds) { + return new Promise(function (resolve) { + setTimeout(resolve, milliseconds); + }); +} + module.exports = function registerOnboardingRoutes(app, deps) { const pool = deps.pool; const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, ''); @@ -42,11 +48,19 @@ module.exports = function registerOnboardingRoutes(app, deps) { { const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode); const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' }); - const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, { - method: 'GET', - headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders) - }); - const resolveBody = await resolveResponse.text(); + let resolveResponse = null; + let resolveBody = ''; + for (let attempt = 0; attempt < 5; attempt += 1) { + resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, { + method: 'GET', + headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders) + }); + resolveBody = await resolveResponse.text(); + if (resolveResponse.status !== 401 || resolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) { + break; + } + await wait(200 * (attempt + 1)); + } let resolved = null; try { resolved = JSON.parse(resolveBody); } catch (_error) {} if (!resolveResponse.ok || !resolved || !resolved.deviceId) { @@ -61,11 +75,19 @@ module.exports = function registerOnboardingRoutes(app, deps) { for (const remoteRegistration of remoteRegistrations) { targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, ''); const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' }); - const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, { - method: 'GET', - headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders) - }); - const bridgeResolveBody = await bridgeResolveResponse.text(); + let bridgeResolveResponse = null; + let bridgeResolveBody = ''; + for (let attempt = 0; attempt < 5; attempt += 1) { + bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, { + method: 'GET', + headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders) + }); + bridgeResolveBody = await bridgeResolveResponse.text(); + if (bridgeResolveResponse.status !== 401 || bridgeResolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) { + break; + } + await wait(200 * (attempt + 1)); + } try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; } if (bridgeResolveResponse.ok && resolved && resolved.deviceId) { break; @@ -86,12 +108,20 @@ module.exports = function registerOnboardingRoutes(app, deps) { } const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug }; const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload }); - const response = await fetch(`${targetBaseUrl}/api/onboarding`, { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders), - body: JSON.stringify(payload) - }); - const responseBody = await response.text(); + let response = null; + let responseBody = ''; + for (let attempt = 0; attempt < 5; attempt += 1) { + response = await fetch(`${targetBaseUrl}/api/onboarding`, { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders), + body: JSON.stringify(payload) + }); + responseBody = await response.text(); + if (response.status !== 401 || responseBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) { + break; + } + await wait(200 * (attempt + 1)); + } res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody); } catch (error) { res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' }); diff --git a/test/notify-player-screens.test.js b/test/notify-player-screens.test.js index 7abbb93..9053072 100644 --- a/test/notify-player-screens.test.js +++ b/test/notify-player-screens.test.js @@ -33,3 +33,37 @@ test('playlist notifications use the player URL reported by the screen connectio command: 'refresh' }]); }); + +test('playlist notifications use the bridge device route for remote player connections', async () => { + const calls = []; + const notify = createNotifyPlayerScreens( + async function () { + calls.push({ type: 'fallback' }); + return { ok: true }; + }, + async function () { + return { + connections: [{ playerDeviceId: 'remote-player-1', playerPublicBaseUrl: 'https://remote.example' }] + }; + }, + async function () { + calls.push({ type: 'base-url' }); + return { ok: true }; + }, + async function () { + return 'http://player:8081'; + }, + async function (deviceId, payload) { + calls.push({ deviceId, payload }); + return { ok: true }; + } + ); + + const count = await notify(['remote-screen'], 'refresh'); + + assert.equal(count, 1); + assert.deepEqual(calls, [{ + deviceId: 'remote-player-1', + payload: { command: 'refresh', screenSlug: 'remote-screen' } + }]); +}); diff --git a/test/player-page-commands-performance.test.js b/test/player-page-commands-performance.test.js index 35a7391..dc8a883 100644 --- a/test/player-page-commands-performance.test.js +++ b/test/player-page-commands-performance.test.js @@ -5,9 +5,15 @@ const test = require('node:test'); const assert = require('node:assert/strict'); function loadCommandsScript(sandbox) { + const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js'); + const mediaPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-media.js'); + const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js'); const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js'); + const animationScript = fs.readFileSync(animationPath, 'utf8'); + const mediaScript = fs.readFileSync(mediaPath, 'utf8'); + const transitionScript = fs.readFileSync(transitionPath, 'utf8'); const commandsScript = fs.readFileSync(commandsPath, 'utf8'); - vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath }); + vm.runInNewContext(animationScript + '\n' + mediaScript + '\n' + transitionScript + '\n' + commandsScript, sandbox, { filename: commandsPath }); } function createSandbox() { @@ -77,7 +83,8 @@ function createSandbox() { templateRenderPlanCache: Object.create(null), renderCacheViewportKey: '', slideTransitionTimer: null, - slideFadeDurationMs: 560, + slideFadeLengthMs: 560, + slideFadeOffsetMs: 280, slideExpiresAt: null, pausedRemainingMs: null, timer: null, @@ -119,6 +126,9 @@ function createSandbox() { test('renderSlideMarkup runs post-render setup immediately', () => { const { sandbox, calls, rafCallbacks, app } = createSandbox(); loadCommandsScript(sandbox); + sandbox.playRegionAnimations = function () { + calls.playRegionAnimations += 1; + }; const returned = sandbox.renderSlideMarkup('
visible
', false); diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index c688f92..3bf5f4e 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -149,6 +149,125 @@ test('playlist refresh queues updates until the next slide transition', async () assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true); }); +test('playlist refresh reloads source data after a cached snapshot receives 304', async () => { + const requests = []; + const sandbox = { + window: null, + location: { origin: 'http://localhost' }, + Date, + JSON, + Math, + Number, + String, + Boolean, + Array, + Object, + Promise, + setTimeout, + clearTimeout, + console, + slug: 'test', + initialData: null, + currentPlaylistEtag: '"cached-etag"', + currentPlaylistSignature: 'cached-signature', + currentPlaylistFadeBetweenSlides: false, + currentPlaylistSkipUnavailableRtmp: false, + pendingPlaylistUpdate: null, + slides: [{ id: 1, duration_seconds: 10 }], + lastRenderedSlide: null, + activeSlidesCacheKey: '', + activeSlidesCacheValue: [], + slideMarkupCache: Object.create(null), + templateLayoutCache: Object.create(null), + templateRenderPlanCache: Object.create(null), + getCurrentActiveSlides() { return sandbox.slides; }, + getPlaylistRevision(data) { return data.signature; }, + normalizeSlide(slide) { return slide; }, + getActiveSlidesFrom(slideList) { return slideList; }, + savePlaylistSnapshot() {}, + markRefreshHealthy() {}, + setOfflineBannerVisible() {}, + scheduleRefreshRetry() {}, + syncWebpagePreloads() {}, + syncRtmpWarmups() {}, + showCurrent() {}, + sendCommandState() {}, + scheduleSlideAdvance() {}, + logDebug() {} + }; + sandbox.window = sandbox; + + class XhrStub { + open(method, url) { + this.method = method; + this.url = url; + requests.push(this); + } + + setRequestHeader(name, value) { + this.headers = this.headers || {}; + this.headers[name] = value; + } + + getResponseHeader() { return ''; } + + send() { + this.readyState = 4; + if (requests.length === 1) { + this.status = 304; + this.responseText = ''; + } else { + this.status = 200; + this.responseText = JSON.stringify({ + signature: 'fresh-signature', + slides: [{ id: 1, duration_seconds: 10 }], + rssFeeds: [], + apiSources: [{ id: 1 }], + timetableGroups: [], + weatherLocations: [{ id: 1 }], + playlist: { fade_between_slides: false, skip_unavailable_rtmp: false } + }); + } + this.onreadystatechange(); + } + } + + sandbox.XMLHttpRequest = XhrStub; + const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js'); + vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }); + + await sandbox.refresh(); + + assert.equal(requests.length, 2); + assert.equal(requests[1].headers && requests[1].headers['If-None-Match'], undefined); + assert.deepEqual(sandbox.initialData.apiSources, [{ id: 1 }]); + assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]); +}); + +test('centralized slide advance timing preserves the configured slide duration', () => { + const sandbox = { + window: null, + currentPlaylistFadeBetweenSlides: false, + slideFadeLengthMs: 560, + slideFadeOffsetMs: 280, + getSlideHoldDelay(value) { + return value; + } + }; + sandbox.window = sandbox; + + const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + vm.runInNewContext(script, sandbox, { filename: scriptPath }); + + assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 12000); + sandbox.currentPlaylistFadeBetweenSlides = true; + assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10 }), 9720); + assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10, use_video_duration: true }), 9440); + assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 11720); + assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440); +}); + test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => { const calls = { showCurrent: 0, @@ -247,8 +366,10 @@ test('single-slide playlists re-render the active slide instead of refreshing af sandbox.XMLHttpRequest = XhrStub; const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js'); + const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js'); const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js'); const playbackScript = fs.readFileSync(scriptPath, 'utf8'); + const transitionScript = fs.readFileSync(transitionPath, 'utf8'); const commandsScript = fs.readFileSync(commandsPath, 'utf8'); const prelude = ` var pendingPlaylistUpdate = null; @@ -270,7 +391,7 @@ test('single-slide playlists re-render the active slide instead of refreshing af var templateLayoutCache = Object.create(null); var templateRenderPlanCache = Object.create(null); `; - vm.runInNewContext(prelude + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath }); + vm.runInNewContext(prelude + '\n' + transitionScript + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath }); sandbox.showCurrent = function () { calls.showCurrent += 1; }; diff --git a/test/upload-sync.test.js b/test/upload-sync.test.js index e4e725b..5c47faa 100644 --- a/test/upload-sync.test.js +++ b/test/upload-sync.test.js @@ -99,7 +99,8 @@ test('fqdn player registration wins over a local configured player target for me assert.equal(success, true); assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote'); + assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin'); + assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote'); } finally { global.fetch = originalFetch; fs.rmSync(uploadDir, { recursive: true, force: true }); @@ -453,9 +454,46 @@ test('slide update sync queues one media task per live player and targets each p assert.deepEqual(fetchCalls.map(function (call) { return call.url; }).sort(), [ - 'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one', - 'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two' + 'http://player-one:8081/api/media/uploads%2Fsample.bin', + 'http://player-two:8081/api/media/uploads%2Fsample.bin' ].sort()); + assert.deepEqual(fetchCalls.map(function (call) { + return call.init.headers['x-pulse-player-device-id']; + }).sort(), ['player-one', 'player-two']); + } finally { + global.fetch = originalFetch; + fs.rmSync(uploadDir, { recursive: true, force: true }); + } +}); + +test('remote media sync uses the bridge device route', async () => { + const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-bridge-')); + fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true }); + fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world')); + const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString(); + const fetchCalls = []; + const originalFetch = global.fetch; + global.fetch = async function (url, init) { + fetchCalls.push({ url, init }); + return { ok: true, status: 200, statusText: 'OK', headers: { get() { return null; } }, async text() { return ''; } }; + }; + + const uploadSyncService = createUploadSyncService({ + common: {}, + bridgeInternalBaseUrl: 'http://player-bridge:8090', + pool: { + async query() { + return [[{ identifier: 'player-remote', internal_base_url: 'https://remote-player.example', last_seen_at: activeLastSeenAt }]]; + } + }, + playerSnapshotCache: new Map(), + notifyPlayerScreens: async () => {} + }); + + try { + assert.equal(await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir), true); + assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/media/uploads%2Fsample.bin'); + assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote'); } finally { global.fetch = originalFetch; fs.rmSync(uploadDir, { recursive: true, force: true });