Add player keyboard controls and release 2.7.1
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m17s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s

This commit is contained in:
2026-08-14 13:50:40 +01:00
parent e7ec276317
commit 75b5cb5a6b
7 changed files with 131 additions and 3 deletions
+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file. 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 ## 2.7.0 - 2026-08-14
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.7.0", "version": "2.7.1",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"main": "src/common.js", "main": "src/common.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.7.0", "version": "2.7.1",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js", "main": "src/common.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.7.0", "version": "2.7.1",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
@@ -427,6 +427,42 @@ function normalizeBoolean(value) {
return null; 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. // Move to the previous or next active slide.
function navigateSlides(offset) { function navigateSlides(offset) {
const manualSlides = getCurrentActiveSlides(); const manualSlides = getCurrentActiveSlides();
@@ -92,6 +92,7 @@ function createSandbox() {
playRegionAnimations() { playRegionAnimations() {
calls.playRegionAnimations += 1; calls.playRegionAnimations += 1;
}, },
addEventListener() {},
pulsePlayerRegionTypes: { pulsePlayerRegionTypes: {
list() { list() {
return [ return [
+85
View File
@@ -68,6 +68,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
scheduleRefreshRetry() {}, scheduleRefreshRetry() {},
syncWebpagePreloads() {}, syncWebpagePreloads() {},
syncRtmpWarmups() {}, syncRtmpWarmups() {},
addEventListener() {},
showCurrent() { showCurrent() {
calls.showCurrent += 1; calls.showCurrent += 1;
}, },
@@ -209,6 +210,7 @@ test('single-slide playlists re-render the active slide instead of refreshing af
scheduleRefreshRetry() {}, scheduleRefreshRetry() {},
syncWebpagePreloads() {}, syncWebpagePreloads() {},
syncRtmpWarmups() {}, syncRtmpWarmups() {},
addEventListener() {},
clearActiveSlidesCache() {}, clearActiveSlidesCache() {},
showCurrent() {}, showCurrent() {},
sendCommandState() {}, sendCommandState() {},
@@ -347,6 +349,7 @@ test('deferred playlist updates preserve the current slide index', async () => {
scheduleRefreshRetry() {}, scheduleRefreshRetry() {},
syncWebpagePreloads() {}, syncWebpagePreloads() {},
syncRtmpWarmups() {}, syncRtmpWarmups() {},
addEventListener() {},
clearActiveSlidesCache() {}, clearActiveSlidesCache() {},
showCurrent() {}, showCurrent() {},
sendCommandState() {}, sendCommandState() {},
@@ -406,6 +409,87 @@ test('deferred playlist updates preserve the current slide index', async () => {
assert.equal(sandbox.index, 1); 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 () => { test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
const calls = { const calls = {
showCurrent: 0, showCurrent: 0,
@@ -464,6 +548,7 @@ test('removing the currently visible slide from a two-slide playlist applies the
scheduleRefreshRetry() {}, scheduleRefreshRetry() {},
syncWebpagePreloads() {}, syncWebpagePreloads() {},
syncRtmpWarmups() {}, syncRtmpWarmups() {},
addEventListener() {},
clearActiveSlidesCache() {}, clearActiveSlidesCache() {},
showCurrent() { showCurrent() {
calls.showCurrent += 1; calls.showCurrent += 1;