diff --git a/src/BookReader.js b/src/BookReader.js index 63d10d2c2..ba5bd2b17 100644 --- a/src/BookReader.js +++ b/src/BookReader.js @@ -704,6 +704,7 @@ BookReader.prototype.init = function() { plugin._bindNavigationHandlers(); } this.setupKeyListeners(); + this._setupUserActionListeners(); this.lastScroll = (new Date().getTime()); this.refs.$brContainer.on('scroll', this, function(e) { @@ -821,6 +822,43 @@ BookReader.prototype.resize = function() { this.trigger(BookReader.eventNames.resize); }; +/** + * Registers the UI wrapping BookReader (e.g. ``), which hosts book + * UI of its own — side menus, panels — outside BookReader's own element. + * + * @param {import('@/src/ia-bookreader/ia-bookreader.js').IaBookReader} shell + */ +BookReader.prototype.initShell = function (shell) { + this.shell = shell; + // Only the shell sees interactions with both its UI and BookReader's own. + this._setupUserActionListeners(shell); +}; + +/** + * Binds listeners that signal the patron is actively using the book. + * + * Consumers outside BookReader (e.g. ia-book-actions) use this to keep a loan + * from expiring while it is being read. The capture phase is required: several + * control handlers return `false`, which jQuery turns into `stopPropagation()`, + * so a bubbling listener would never see clicks on the navbar. + * + * @private + * @param {HTMLElement} [target] element to listen on; prefer the outermost one + * that holds book UI, since anything outside it is invisible to these listeners. + */ +BookReader.prototype._setupUserActionListeners = function (target = this.refs.$br[0]) { + if (!target || target === this._userActionTarget) return; + + if (!this._onUserAction) { + this._onUserAction = () => this.trigger(BookReader.eventNames.userAction); + } + for (const eventName of ['pointerdown', 'keydown']) { + this._userActionTarget?.removeEventListener(eventName, this._onUserAction, { capture: true }); + target.addEventListener(eventName, this._onUserAction, { capture: true }); + } + this._userActionTarget = target; +}; + /** * Binds keyboard and keyboard focus event listeners */ diff --git a/src/BookReader/Navbar/Navbar.js b/src/BookReader/Navbar/Navbar.js index 4daafcb31..ebdaeaadb 100644 --- a/src/BookReader/Navbar/Navbar.js +++ b/src/BookReader/Navbar/Navbar.js @@ -170,12 +170,6 @@ export class Navbar { }, }; - // custom event for auto-loan-renew in ia-book-actions - // - to know if user is actively reading - this.$nav.find('nav.BRcontrols li button').on('click', () => { - this.br.trigger(EVENTS.userAction); - }); - for (const control in navigationControls) { jIcons.filter(`.${control}`).on('click.bindNavigationHandlers', () => { navigationControls[control](); diff --git a/src/ia-bookreader/ia-bookreader.js b/src/ia-bookreader/ia-bookreader.js index e8c7a29a5..d4567d01f 100644 --- a/src/ia-bookreader/ia-bookreader.js +++ b/src/ia-bookreader/ia-bookreader.js @@ -497,7 +497,7 @@ export class IaBookReader extends LitElement { _bindEventListeners() { window.addEventListener('BookReader:PostInit', /** @param {CustomEvent} e */ (e) => { this.bookreader = e.detail.props; - this.bookreader.shell = this; + this.bookreader.initShell(this); this.bookReaderLoaded = true; this.bookReaderCannotLoad = false; this.loaded = true; diff --git a/src/plugins/tts/WebTTSEngine.js b/src/plugins/tts/WebTTSEngine.js index 9d0fad36a..c95569595 100644 --- a/src/plugins/tts/WebTTSEngine.js +++ b/src/plugins/tts/WebTTSEngine.js @@ -57,10 +57,12 @@ export default class WebTTSEngine extends AbstractTTSEngine { }); navigator.mediaSession.setActionHandler('play', () => { + this.events.trigger('mediaSessionAction'); audio.play(); this.resume(); }); navigator.mediaSession.setActionHandler('pause', () => { + this.events.trigger('mediaSessionAction'); audio.pause(); this.pause(); }); @@ -70,8 +72,14 @@ export default class WebTTSEngine extends AbstractTTSEngine { // Some devices only support the previoustrack/nexttrack (e.g. Win10), so show those. // Android devices do support the seek actions, but we don't want to show both buttons // and have them do the same thing. - navigator.mediaSession.setActionHandler('previoustrack', () => this.jumpBackward()); - navigator.mediaSession.setActionHandler('nexttrack', () => this.jumpForward()); + navigator.mediaSession.setActionHandler('previoustrack', () => { + this.events.trigger('mediaSessionAction'); + this.jumpBackward(); + }); + navigator.mediaSession.setActionHandler('nexttrack', () => { + this.events.trigger('mediaSessionAction'); + this.jumpForward(); + }); }); } diff --git a/src/plugins/tts/plugin.tts.js b/src/plugins/tts/plugin.tts.js index a5d4433ff..01c3f09ee 100644 --- a/src/plugins/tts/plugin.tts.js +++ b/src/plugins/tts/plugin.tts.js @@ -197,6 +197,9 @@ export class TtsPlugin extends BookReaderPlugin { renderVoicesMenu(voicesMenu); voicesMenu.on("change", ev => this.ttsEngine.setVoice(voicesMenu.val())); this.ttsEngine.events.on('pause resume start', () => this.updateState()); + // Media session controls (lock screen, notification shade, headset buttons) + // have no DOM presence, so BookReader's own listeners cannot see them. + this.ttsEngine.events.on('mediaSessionAction', () => this.br.trigger(BookReader.eventNames.userAction)); this.ttsEngine.events.on('voiceschanged', () => renderVoicesMenu(voicesMenu)); this.br.on('translationEnabled', () => renderVoicesMenu(voicesMenu)); this.br.on('translationDisabled', () => renderVoicesMenu(voicesMenu)); diff --git a/tests/jest/BookReader.userAction.test.js b/tests/jest/BookReader.userAction.test.js new file mode 100644 index 000000000..c209bf6bb --- /dev/null +++ b/tests/jest/BookReader.userAction.test.js @@ -0,0 +1,70 @@ +import BookReader from '@/src/BookReader.js'; + +/** @type {BookReader} */ +let br; + +beforeEach(() => { + document.body.innerHTML = '
'; + br = new BookReader({ data: [[{ width: 123, height: 123, uri: 'https://archive.org/image0.jpg', pageNum: '1' }]] }); + br.init(); +}); + +/** @returns {jest.Mock} */ +function listenForUserAction() { + const listener = jest.fn(); + window.addEventListener(`BookReader:${BookReader.eventNames.userAction}`, listener); + return listener; +} + +test('clicking anywhere in the BookReader signals a user action', () => { + const listener = listenForUserAction(); + br.refs.$brContainer[0].dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(listener).toHaveBeenCalled(); +}); + +test('typing anywhere in the BookReader signals a user action', () => { + const listener = listenForUserAction(); + br.refs.$brContainer[0].dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })); + expect(listener).toHaveBeenCalled(); +}); + +test('signals a user action even when a control stops propagation', () => { + const $button = br.refs.$br.find('nav.BRcontrols button').first(); + expect($button.length).toBe(1); + $button.on('pointerdown', (ev) => ev.stopPropagation()); + + const listener = listenForUserAction(); + $button[0].dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(listener).toHaveBeenCalled(); +}); + +test('does not signal a user action for events outside the BookReader', () => { + const listener = listenForUserAction(); + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(listener).not.toHaveBeenCalled(); +}); + +describe('when a shell wraps the BookReader', () => { + /** @type {HTMLElement} */ + let shell; + + beforeEach(() => { + shell = document.createElement('div'); + br.refs.$br[0].replaceWith(shell); + shell.appendChild(br.refs.$br[0]); + br.initShell(shell); + }); + + test('signals a user action for UI outside the BookReader element', () => { + const sibling = shell.appendChild(document.createElement('nav')); + const listener = listenForUserAction(); + sibling.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(listener).toHaveBeenCalled(); + }); + + test('signals a user action exactly once for UI inside the BookReader', () => { + const listener = listenForUserAction(); + br.refs.$brContainer[0].dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/jest/ia-bookreader/ia-bookreader.test.js b/tests/jest/ia-bookreader/ia-bookreader.test.js index b2c20ff99..cbea2fea6 100644 --- a/tests/jest/ia-bookreader/ia-bookreader.test.js +++ b/tests/jest/ia-bookreader/ia-bookreader.test.js @@ -74,6 +74,7 @@ describe('', () => { test('Listens for Global Event @BookReader:PostInit', async () => { const brStub = { resize: sinon.fake(), + initShell: sinon.fake(), currentIndex: sinon.fake(), jumpToIndex: sinon.fake(), options: { enableMultipleBooks: false }, // for multipleBooks