diff --git a/CHANGELOG.md b/CHANGELOG.md index 5670fa3..9cfc51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.7.1 - 2026-08-14 + +### Changed + +- The player page now supports keyboard navigation with arrow keys to move between slides. + ## 2.7.0 - 2026-08-14 ### Added diff --git a/build/package.player.json b/build/package.player.json index 86c102e..3d9e2a6 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.7.0", + "version": "2.7.1", "private": false, "description": "Pulse Signage player application bundle", "main": "src/common.js", diff --git a/build/package.web.json b/build/package.web.json index bc13912..a1f8706 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.7.0", + "version": "2.7.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index ddba884..2d9c865 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.7.0", + "version": "2.7.1", "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 b036ecd..9364a08 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -427,6 +427,42 @@ function normalizeBoolean(value) { return null; } +function isEditableTarget(target) { + if (!target) { + return false; + } + + if (target.isContentEditable) { + return true; + } + + var tagName = String(target.tagName || '').toUpperCase(); + return ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'].indexOf(tagName) !== -1; +} + +function handlePlayerKeydown(event) { + if (!event || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + if (isEditableTarget(event.target)) { + return; + } + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + navigateSlides(-1); + return; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + navigateSlides(1); + } +} + +window.addEventListener('keydown', handlePlayerKeydown); + // Move to the previous or next active slide. function navigateSlides(offset) { const manualSlides = getCurrentActiveSlides(); diff --git a/test/player-page-commands-performance.test.js b/test/player-page-commands-performance.test.js index 38e9a76..35a7391 100644 --- a/test/player-page-commands-performance.test.js +++ b/test/player-page-commands-performance.test.js @@ -92,6 +92,7 @@ function createSandbox() { playRegionAnimations() { calls.playRegionAnimations += 1; }, + addEventListener() {}, pulsePlayerRegionTypes: { list() { return [ diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index 120b151..c688f92 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -68,6 +68,7 @@ test('playlist refresh queues updates until the next slide transition', async () scheduleRefreshRetry() {}, syncWebpagePreloads() {}, syncRtmpWarmups() {}, + addEventListener() {}, showCurrent() { calls.showCurrent += 1; }, @@ -209,6 +210,7 @@ test('single-slide playlists re-render the active slide instead of refreshing af scheduleRefreshRetry() {}, syncWebpagePreloads() {}, syncRtmpWarmups() {}, + addEventListener() {}, clearActiveSlidesCache() {}, showCurrent() {}, sendCommandState() {}, @@ -347,6 +349,7 @@ test('deferred playlist updates preserve the current slide index', async () => { scheduleRefreshRetry() {}, syncWebpagePreloads() {}, syncRtmpWarmups() {}, + addEventListener() {}, clearActiveSlidesCache() {}, showCurrent() {}, sendCommandState() {}, @@ -406,6 +409,87 @@ test('deferred playlist updates preserve the current slide index', async () => { assert.equal(sandbox.index, 1); }); +test('player page arrow keys move between slides', async () => { + const handlers = Object.create(null); + const calls = []; + + const sandbox = { + window: null, + console, + Array, + Object, + String, + Boolean, + Number, + Math, + Date, + JSON, + Promise, + setTimeout, + clearTimeout, + location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' }, + addEventListener(type, handler) { + handlers[type] = handler; + }, + slides: [{ id: 1 }, { id: 2 }, { id: 3 }], + lastRenderedSlide: { id: 2 }, + index: 1, + timer: null, + slideOutroTimers: [], + getCurrentActiveSlides() { + return sandbox.slides; + }, + clearSlideTimer() {}, + applyPendingPlaylistUpdate() {}, + renderSlideAtIndex(_sourceSlides, targetIndex) { + calls.push(targetIndex); + } + }; + sandbox.window = sandbox; + + const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + vm.runInNewContext(script, sandbox, { filename: scriptPath }); + + assert.equal(typeof handlers.keydown, 'function'); + + let prevented = false; + handlers.keydown({ + key: 'ArrowLeft', + target: {}, + preventDefault() { + prevented = true; + } + }); + + assert.deepEqual(calls, [0]); + assert.equal(prevented, true); + + sandbox.lastRenderedSlide = { id: 2 }; + sandbox.index = 1; + prevented = false; + handlers.keydown({ + key: 'ArrowRight', + target: {}, + preventDefault() { + prevented = true; + } + }); + + assert.deepEqual(calls, [0, 2]); + assert.equal(prevented, true); + + handlers.keydown({ + key: 'ArrowLeft', + target: { tagName: 'INPUT' }, + preventDefault() { + throw new Error('should not be called for editable targets'); + } + }); + + assert.deepEqual(calls, [0, 2]); +}); + test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => { const calls = { showCurrent: 0, @@ -464,6 +548,7 @@ test('removing the currently visible slide from a two-slide playlist applies the scheduleRefreshRetry() {}, syncWebpagePreloads() {}, syncRtmpWarmups() {}, + addEventListener() {}, clearActiveSlidesCache() {}, showCurrent() { calls.showCurrent += 1;