From 21b4a5225bf98cc4a491d62625fd6f0d40fdd3e8 Mon Sep 17 00:00:00 2001 From: Borut Zizmond Date: Thu, 18 Apr 2024 03:34:52 +0200 Subject: [PATCH] feat: implement spatial navigation (#8570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(player): add spatialNavigation feature Adds spatialNavigation feature to enhance user experience - Implemented spatial navigation in slider component - Enhanced player functionality for improved navigation * feat(player): add spatialNavigation class Adds spatialNavigation class to manage spatial-navigation-polyfill - Set class SpatialNavigation on its own file - Imported SpatialNavigation class on component class * feat(player): update spatialNavigation class Adds 3 methods to spatialNavigation class to manage spatial-navigation-polyfill - Added start() to: Start listen of keydown events - Added stop() to: Stop listen key down events - Added getComponents() to: Get current focusable components * feat(player): modify spatialNavigation class & modify component class Modify spatialNavigation class: -Remove unrequired version of function ‘getComponents’ Modify component class: -Add function ‘getIsFocusable’ * Added methods getPositions, handleFocus and handleBLur for spatial navigation needs * feat(player): modify Component class, BigPlayButton class & ClickableComponent class Modify Component class: -Add method getIsAvailableToBeFocused -Modify method getIsFocusable to only focus on finding focusable candidates Modify spatialNavigation class: -Remove unrequired method ‘getIsFocusable’ Modify component class: -Remove unrequired method ‘getIsFocusable’ * Added import in player.js, Created base methods inside spatial-navigation.js * feat(player): modify Component class & SpatialNavigation class Modify Component class: -Modify method getIsAvailableToBeFocused to be more strict on candidates Modify spatialNavigation class: -Modify method getComponents to get all focusable components * feat(player): modify Component class Modify Component class: -Add documentation to ‘isVisible’ function * added keydown event logic for spatial-navigation * feat(player): modify SpatialNavigation class Modify SpatialNavigation class: -Modify documentation of functions * feat(player): modify SpatialNavigation class Modify SpatialNavigation class: -Add ‘clear’ & ‘remove’ methods * feat(player): modify SpatialNavigation class Modify SpatialNavigation class: -Add documentation of functions * feat(player): modify SpatialNavigation class Modify SpatialNavigation class: -Add function ‘getCurretComponent’‘’ * feat(player): modify SpatialNavigation class Modify SpatialNavigation class: -Add documentation for ‘findBestCandidate’ method * Added logic for moving focus to the best candidate * Implemented move, findBestCandidate, isInDirection, and calculateDistance methods for spatial navigation logic * Added a new player option enableKeydownListener, Added gap: 1px to control-bar for spatial-navigation-polyfill needs * feat(player): modify SpatialNavigation class & Component class Modify SpatialNavigation class: -Add function ‘handlePlayerBlur’ -Add function ‘handlePlayerFocus’ Modify Component class: -Modify ‘handleBlur’ -Modify ‘handleFocus’ * Removed enableKeydownListener flag, as user should start the SpatialNavigation manually * Added functionality to track changes in the focusableComponents list (custom event focusableComponentsChanged) * feat(player): modify SpatialNavigation class, ModalDialog & Component class Modify SpatialNavigation class: -Add ‘lastFocusedComponent’ -Add function ‘refocusComponent’ Modify ModalDialog class: -Add condition on ‘close’ function Modify Component class: -Modify ‘handleBlur’ to store blurred component * feat(player): modify ModalDialog Modify ModalDialog: -Add condition to close Modal on Backspace * Refactor SpatialNavigation to use player.spatialNavigation * Added a new custom event endOfFocusableComponents * Added new styles for focused elements in case spatial navigation is enabled * feat(player): modify SpatialNavigation class: -Add condition so getComponents can get as candidates the UI elements from the playlist-ui * Changed to window.SpatialNabigation to this.player_.spatialNavigation * feat(player): modify text-track-settings, created test-track-settings-colors.js, text-track-settings-font.js,text-track-fieldset.js & text-track-select.js: Modify text-track-settings class: - Add changes so newly created components can work as content of the modal. - Create new components as a refactor of the contents of text-track-settings * changed handleKeyDown inside component.js, getComponents method is now iterating player.children * feat(player): create TrackSettingsControls Component & Modify TextTrackSettings Create TrackSettingsControls Component: -Create Component to show buttons reset & done as components. Modify TextTrackSettings: -Add Component TrackSettingsControls in TextTrackSettings * feat(player): Modify ModalDialog Modify ModalDialog: -Add condition for stop propagation of event inside of ModalDialog when spatialNavigation is enabled * getIsFocusable and getIsAvailableToBeFocused methods are now accepting el as a parameter, added a new methods findSuitableDOMChild and focus for spatialNavigation class * feat(player): Modify TextTrackSettings: Modify TextTrackSettings: -Remove unrequired methods to create DOM elements since now those are created by Components. * feat(player): Modify CaptionSettingsMenuItem: Modify CaptionSettingsMenuItem: -Add condition to focus component of TextTrackSelect when modal is open * feat(player): Modify TextTrackSelect & TextTrackFieldset: Modify TextTrackSelect : Modify TextTrackFieldset: -Add comments to certain functions to explain the code * feat(player): Modify TrackSettingsControls: Modify TrackSettingsControls: -Remove unrequired comments & add comments to certain functions to explain the code * feat(player): Modify SpatialNavigation, Component & ModalDialog: Modify SpatialNavigation: Modify Component: Modify ModalDialog: -Add & update comments of documentation. * Handle ENTER keydown in Modals when spatial navigation is enabled * feat(player): Modify ModalDialog, spatialNavigation, TrackSettingsControls, TextTrackFieldset, TextTrackSelect, TrackSettingsColors, TrackSettingsFont: Modify ModalDialog: Modify spatialNavigation: Modify TrackSettingsControls: Modify TextTrackFieldset: Modify TextTrackSelect: Modify TrackSettingsColors: Modify TrackSettingsFont: -Add & update comments of documentation. * Implement additional RCU controls * feat(player): Modify Component class: Modify Component : -Remove unrequired condition inside of handleFocus method. * feat(player): Modify ModalDialog & CaptionSettingsMenuItem Modify ModalDialog: Modify CaptionSettingsMenuItem: -Modify spatialNavigation condition to be more specific regarding spatialNavigation implementation. * feat(player): Modify SpatialNavigation class: Modify SpatialNavigation : -Fix bug where ‘enter’ press was not working properly on select component inside of the ‘vjs-text-track-settings’ modal. * feat(player): Modify SpatialNavigation class: Modify SpatialNavigation : -Minor improvements on the loops of certain functions to stop when they have found the element they are looking for. -Implement minor spacing formatting on switch statement. * Update src/js/component.js More understandable documentation. Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com> * Update src/js/component.js More understandable documentation. Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com> * feat(player): Modify SpatialNavigation & Component class: Modify Component class : Modify SpatialNavigation class : -Modify ‘getIsFocusable’ function to use ‘this.el_’ instead of ‘el’ parameter * feat(player): Modify SpatialNavigation class: Modify SpatialNavigation class : -Refactor onKeyDown function to use static data & return when pause is true. * feat(player): Modify SpatialNavigation class: Modify SpatialNavigation class : -Refactor to use ‘.el()’ instead of ‘.el_’ * Update src/js/spatial-navigation.js Co-authored-by: Walter Seymour * feat(player): Modify ModalDialog class & MenuItem class: Modify ModalDialog class : Modify MenuItem class : -Correct typo of ‘isSpatialNavlistening’ to ‘isSpatialNavListening’. * removed unused property, remove this.focus, which was added for testing purposes * Changed parameters to private, removed redundant code, removed initialFocusedComponent parameter, change STEP_SECONDS to static * feat(player): solve remaining conflict: Modify Spatial Navigation class : - Solve conflict * feat(player): Rename TrackSettingsColors & TrackSettingsFont * feat(player): Remove unrequired functions calls from components TextTrackSettingsColors & TextTrackSettingsFont. * feat(player): Update spatial-navigation.js's keypress return keyword. * bind focus and blur just if spatial navigation is enabled, add 1px gap if spatial navigation is enabled * feat(player): Modify calls on 'isListening' & 'isPaused' for ModalDialog & TextTrackMenuItem * feat(player): remove unrequired object on component 'TrackSettingsControls' * Removed 1px gap * feat(player): Rename function ‘getComponents’ to ‘updateFocusableComponents’ * Changed SpatialNavigation class to extend EventTarget, removed redundant methods for events * fix(player): fix call of 'getIsAvailableToBeFocused' that was throwing an error. * removed Static maps for key presses and extended keycode with the missing keys * refactor(player): Modify functions of 'getIsDisabled', 'getIsExpresslyInert' & 'getIsFocusable' to be more in pair when stablished code of the player. * Conditional assignment for keycode.codes.back based on platform, changed Backspace to Back key for Modal closing * Extend the object for reverse lookup, prenet Up/down keys to open a menu if spatial navigation is anabled * refactor(player): Refactor 'SpatialNavKeycodes' file to not patch 'keycode' dependency * fix(pllayer): fix issue related to 'back' not being used properly in function 'isEventKey' * feat(player): Rename imports of 'spatial-navigation-keycode' to have their extension * feat(player): Add example of use of 'Client app uses a global spatial-navigation solution' * feat(player): rename 'spatial-navigation-keycode.js' filename * Fix on src chnage issue, ESC button closing modal, expand vjs-modal-dialog * change file name and object name * fix: Update ids of labels to use 'guid' so unit test works properly * fix: update localized text in text-track-settings-font & text-track-settings * Mark some methods as private * fix: modify content of modal 'text-track-settings' to change language properly * fix: add missing '.' in jsdoc of text-track components * feature: add unit test for 'text-track-select' component * Add test for Spatial Navigation * test(player): Add minor test related to 'handleBlur' & 'handleFocus' * feat(player): Remove unrequired files from 'react-video-nav-app' * test(player): Add small test to check if 'getPositions' returns required properties * test(player): add test to verify 'getPositions()' properties are not empty * Add missing tests for performMediaAction_ and move * test(player): add test to for 'component.js' related to 'handleBlur' * test(player): add minor test in component related to test keypress propagation event * test(player): add test for component related to 'getIsAvailableToBeFocused' function * test(player): add test for Modal Dialog related to call function of spatial navigation * test(player): add tests for 'spatial-navigation-key-codes' * test(player): add tests for keycodes related to 'should return event name if keyCode is not available' * test(player): add minor test for case when not required parametters are passed * test(player): add test for 'caption-settings-menu-item' * feat(player): remove 'react-video-nav-app' * Move handleFocus and handleBlur from components.js to spatial-navigation.js * refactor(player): refactor 'searchForTrackSelect' to be handled in the spatial navigation * remove unrequired code in function 'searchForTrackSelect' * update documentation comment to be in pair to its current use * remove spatial navigation keydown from modal dialog and move it to spatial navigation class, modify the modal-dialog test accordingly * remove useless tests * Remove caption-settings-menu-item.test.js * Add minor test to 'searchForTrackSelect' in spatial-navigation.test.js * Add unit test for back key and listening to events --------- Co-authored-by: CarlosVillasenor Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com> Co-authored-by: Walter Seymour Co-authored-by: Carlos Villasenor Castillo --- src/css/components/_button.scss | 6 + src/css/components/_captions-settings.scss | 5 + src/css/components/_control-bar.scss | 4 + src/css/components/_slider.scss | 5 + src/js/component.js | 195 +++++- src/js/menu/menu-button.js | 2 +- src/js/modal-dialog.js | 15 +- src/js/player.js | 12 + src/js/slider/slider.js | 26 +- src/js/spatial-navigation.js | 553 ++++++++++++++++++ src/js/tracks/text-track-fieldset.js | 129 ++++ src/js/tracks/text-track-select.js | 79 +++ src/js/tracks/text-track-settings-colors.js | 104 ++++ src/js/tracks/text-track-settings-controls.js | 60 ++ src/js/tracks/text-track-settings-font.js | 101 ++++ src/js/tracks/text-track-settings.js | 291 +++------ src/js/utils/spatial-navigation-key-codes.js | 47 ++ test/unit/component.test.js | 112 ++++ test/unit/spatial-navigation.test.js | 493 ++++++++++++++++ test/unit/tracks/text-track-select.test.js | 25 + .../spatial-navigation-key-codes.test.js | 96 +++ 21 files changed, 2125 insertions(+), 235 deletions(-) create mode 100644 src/js/spatial-navigation.js create mode 100644 src/js/tracks/text-track-fieldset.js create mode 100644 src/js/tracks/text-track-select.js create mode 100644 src/js/tracks/text-track-settings-colors.js create mode 100644 src/js/tracks/text-track-settings-controls.js create mode 100644 src/js/tracks/text-track-settings-font.js create mode 100644 src/js/utils/spatial-navigation-key-codes.js create mode 100644 test/unit/spatial-navigation.test.js create mode 100644 test/unit/tracks/text-track-select.test.js create mode 100644 test/unit/utils/spatial-navigation-key-codes.test.js diff --git a/src/css/components/_button.scss b/src/css/components/_button.scss index 3c7ae28ddc..e14a92187e 100644 --- a/src/css/components/_button.scss +++ b/src/css/components/_button.scss @@ -15,6 +15,12 @@ appearance: none; } +// Replacement for focus in case spatial navigation is enabled +.video-js.vjs-spatial-navigation-enabled .vjs-button:focus { + outline: 0.0625em solid rgba($primary-foreground-color, 1); + box-shadow: none; +} + .vjs-control .vjs-button { width: 100%; height: 100%; diff --git a/src/css/components/_captions-settings.scss b/src/css/components/_captions-settings.scss index d36c8588ae..29c7bfd5b4 100644 --- a/src/css/components/_captions-settings.scss +++ b/src/css/components/_captions-settings.scss @@ -3,6 +3,11 @@ background-color: rgba($primary-background-color, 0.75); color: $primary-foreground-color; height: 70%; + + // When Spatial Navigation is enabled + .vjs-spatial-navigation-enabled & { + height: 80%; + } } // Hide if an error occurs diff --git a/src/css/components/_control-bar.scss b/src/css/components/_control-bar.scss index ae72c54185..3679539631 100644 --- a/src/css/components/_control-bar.scss +++ b/src/css/components/_control-bar.scss @@ -10,6 +10,10 @@ @include background-color-with-alpha($primary-background-color, $primary-background-transparency); } +.video-js.vjs-spatial-navigation-enabled .vjs-control-bar { + gap: 1px; +} + // Locks the display only if: // - controls are not disabled // - native controls are not used diff --git a/src/css/components/_slider.scss b/src/css/components/_slider.scss index ca1663dfad..2843fd3de0 100644 --- a/src/css/components/_slider.scss +++ b/src/css/components/_slider.scss @@ -18,3 +18,8 @@ @include box-shadow(0 0 1em $primary-foreground-color); } + +// Replacement for focus in case spatial navigation is enabled +.video-js.vjs-spatial-navigation-enabled .vjs-slider:focus { + outline: 0.0625em solid rgba($primary-foreground-color, 1); +} diff --git a/src/js/component.js b/src/js/component.js index 3d7d2d8f20..e1c157796c 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -1283,6 +1283,49 @@ class Component { return this.currentDimension('height'); } + /** + * Retrieves the position and size information of the component's element. + * + * @return {Object} An object with `boundingClientRect` and `center` properties. + * - `boundingClientRect`: An object with properties `x`, `y`, `width`, + * `height`, `top`, `right`, `bottom`, and `left`, representing + * the bounding rectangle of the element. + * - `center`: An object with properties `x` and `y`, representing + * the center point of the element. `width` and `height` are set to 0. + */ + getPositions() { + const rect = this.el_.getBoundingClientRect(); + + // Creating objects that mirror DOMRectReadOnly for boundingClientRect and center + const boundingClientRect = { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left + }; + + // Calculating the center position + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + width: 0, + height: 0, + top: rect.top + rect.height / 2, + right: rect.left + rect.width / 2, + bottom: rect.top + rect.height / 2, + left: rect.left + rect.width / 2 + }; + + return { + boundingClientRect, + center + }; + } + /** * Set the focus to this component */ @@ -1308,8 +1351,8 @@ class Component { if (this.player_) { // We only stop propagation here because we want unhandled events to fall - // back to the browser. Exclude Tab for focus trapping. - if (!keycode.isEventKey(event, 'Tab')) { + // back to the browser. Exclude Tab for focus trapping, exclude also when spatialNavigation is enabled. + if (!keycode.isEventKey(event, 'Tab') && !(this.player_.options_.playerOptions.spatialNavigation && this.player_.options_.playerOptions.spatialNavigation.enabled)) { event.stopPropagation(); } this.player_.handleKeyDown(event); @@ -1765,6 +1808,154 @@ class Component { }); } + /** + * Decide whether an element is actually disabled or not. + * + * @function isActuallyDisabled + * @param element {Node} + * @return {boolean} + * + * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled} + */ + getIsDisabled() { + return Boolean(this.el_.disabled); + } + + /** + * Decide whether the element is expressly inert or not. + * + * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#expressly-inert} + * @function isExpresslyInert + * @param element {Node} + * @return {boolean} + */ + getIsExpresslyInert() { + return this.el_.inert && !this.el_.ownerDocument.documentElement.inert; + } + + /** + * Determine whether or not this component can be considered as focusable component. + * + * @param {HTMLElement} el - The HTML element representing the component. + * @return {boolean} + * If the component can be focused, will be `true`. Otherwise, `false`. + */ + getIsFocusable() { + return this.el_.tabIndex >= 0 && !(this.getIsDisabled() || this.getIsExpresslyInert()); + } + + /** + * Determine whether or not this component is currently visible/enabled/etc... + * + * @param {HTMLElement} el - The HTML element representing the component. + * @return {boolean} + * If the component can is currently visible & enabled, will be `true`. Otherwise, `false`. + */ + getIsAvailableToBeFocused(el) { + /** + * Decide the style property of this element is specified whether it's visible or not. + * + * @function isVisibleStyleProperty + * @param element {CSSStyleDeclaration} + * @return {boolean} + */ + function isVisibleStyleProperty(element) { + const elementStyle = window.getComputedStyle(element, null); + const thisVisibility = elementStyle.getPropertyValue('visibility'); + const thisDisplay = elementStyle.getPropertyValue('display'); + const invisibleStyle = ['hidden', 'collapse']; + + return (thisDisplay !== 'none' && !invisibleStyle.includes(thisVisibility)); + } + + /** + * Decide whether the element is being rendered or not. + * 1. If an element has the style as "visibility: hidden | collapse" or "display: none", it is not being rendered. + * 2. If an element has the style as "opacity: 0", it is not being rendered.(that is, invisible). + * 3. If width and height of an element are explicitly set to 0, it is not being rendered. + * 4. If a parent element is hidden, an element itself is not being rendered. + * (CSS visibility property and display property are inherited.) + * + * @see {@link https://html.spec.whatwg.org/multipage/rendering.html#being-rendered} + * @function isBeingRendered + * @param element {Node} + * @return {boolean} + */ + function isBeingRendered(element) { + if (!isVisibleStyleProperty(element.parentElement)) { + return false; + } + if (!isVisibleStyleProperty(element) || (element.style.opacity === '0') || (window.getComputedStyle(element).height === '0px' || window.getComputedStyle(element).width === '0px')) { + return false; + } + return true; + } + + /** + * Determine if the element is visible for the user or not. + * 1. If an element sum of its offsetWidth, offsetHeight, height and width is less than 1 is not visible. + * 2. If elementCenter.x is less than is not visible. + * 3. If elementCenter.x is more than the document's width is not visible. + * 4. If elementCenter.y is less than 0 is not visible. + * 5. If elementCenter.y is the document's height is not visible. + * + * @function isVisible + * @param element {Node} + * @return {boolean} + */ + function isVisible(element) { + if ((element.offsetWidth + element.offsetHeight + element.getBoundingClientRect().height + element.getBoundingClientRect().width) === 0) { + return false; + } + + // Define elementCenter object with props of x and y + // x: Left position relative to the viewport plus element's width (no margin) divided between 2. + // y: Top position relative to the viewport plus element's height (no margin) divided between 2. + const elementCenter = { + x: element.getBoundingClientRect().left + element.offsetWidth / 2, + y: element.getBoundingClientRect().top + element.offsetHeight / 2 + }; + + if (elementCenter.x < 0) { + return false; + } + if (elementCenter.x > (document.documentElement.clientWidth || window.innerWidth)) { + return false; + } + if (elementCenter.y < 0) { + return false; + } + if (elementCenter.y > (document.documentElement.clientHeight || window.innerHeight)) { + return false; + } + + let pointContainer = document.elementFromPoint(elementCenter.x, elementCenter.y); + + while (pointContainer) { + if (pointContainer === element) { + return true; + } + if (pointContainer.parentNode) { + pointContainer = pointContainer.parentNode; + } else { + return false; + } + + } + } + + // If no DOM element was passed as argument use this component's element. + if (!el) { + el = this.el(); + } + + // If element is visible, is being rendered & either does not have a parent element or its tabIndex is not negative. + if (isVisible(el) && isBeingRendered(el) && ((!el.parentElement) || (el.tabIndex >= 0))) { + return true; + } + return false; + } + /** * Register a `Component` with `videojs` given the name and the component. * diff --git a/src/js/menu/menu-button.js b/src/js/menu/menu-button.js index 98754ac86e..4dafbf5601 100644 --- a/src/js/menu/menu-button.js +++ b/src/js/menu/menu-button.js @@ -308,7 +308,7 @@ class MenuButton extends Component { this.menuButton_.focus(); } // Up Arrow or Down Arrow also 'press' the button to open the menu - } else if (keycode.isEventKey(event, 'Up') || keycode.isEventKey(event, 'Down')) { + } else if ((keycode.isEventKey(event, 'Up') || keycode.isEventKey(event, 'Down')) && !(this.player_.options_.playerOptions.spatialNavigation && this.player_.options_.playerOptions.spatialNavigation.enabled)) { if (!this.buttonPressed_) { event.preventDefault(); this.pressButton(); diff --git a/src/js/modal-dialog.js b/src/js/modal-dialog.js index 1c9a3a00fe..4b841f0c33 100644 --- a/src/js/modal-dialog.js +++ b/src/js/modal-dialog.js @@ -21,7 +21,7 @@ const MODAL_CLASS_NAME = 'vjs-modal-dialog'; class ModalDialog extends Component { /** - * Create an instance of this class. + * Creates an instance of this class. * * @param { import('./player').default } player * The `Player` that this class should be attached to. @@ -236,6 +236,7 @@ class ModalDialog extends Component { if (!this.opened_) { return; } + const player = this.player(); /** @@ -265,8 +266,10 @@ class ModalDialog extends Component { * * @event ModalDialog#modalclose * @type {Event} + * + * @property {boolean} [bubbles=true] */ - this.trigger('modalclose'); + this.trigger({type: 'modalclose', bubbles: true}); this.conditionalBlur_(); if (this.options_.temporary) { @@ -454,7 +457,13 @@ class ModalDialog extends Component { * @listens keydown */ handleKeyDown(event) { - + /** + * Fired a custom keyDown event that bubbles. + * + * @event ModalDialog#modalKeydown + * @type {Event} + */ + this.trigger({type: 'modalKeydown', originalEvent: event, target: this, bubbles: true}); // Do not allow keydowns to reach out of the modal dialog. event.stopPropagation(); diff --git a/src/js/player.js b/src/js/player.js index 8eceeaa379..a2027003da 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -36,6 +36,7 @@ import {hooks} from './utils/hooks'; import {isObject} from './utils/obj'; import keycode from 'keycode'; import icons from '../images/icons.svg'; +import SpatialNavigation from './spatial-navigation.js'; // The following imports are used only to ensure that the corresponding modules // are always included in the video.js package. Importing the modules will @@ -562,6 +563,13 @@ class Player extends Component { this.addClass('vjs-audio'); } + // Check if spatial navigation is enabled in the options. + // If enabled, instantiate the SpatialNavigation class. + if (options.spatialNavigation && options.spatialNavigation.enabled) { + this.spatialNavigation = new SpatialNavigation(this); + this.addClass('vjs-spatial-navigation-enabled'); + } + // TODO: Make this smarter. Toggle user state between touching/mousing // using events, since devices can have both touch and mouse events. // TODO: Make this check be performed again when the window switches between monitors @@ -5447,6 +5455,10 @@ Player.prototype.options_ = { responsive: false, audioOnlyMode: false, audioPosterMode: false, + spatialNavigation: { + enabled: false, + horizontalSeek: false + }, // Default smooth seeking to false enableSmoothSeeking: false }; diff --git a/src/js/slider/slider.js b/src/js/slider/slider.js index 828b689659..1ed1cfb769 100644 --- a/src/js/slider/slider.js +++ b/src/js/slider/slider.js @@ -308,14 +308,32 @@ class Slider extends Component { * @listens keydown */ handleKeyDown(event) { - - // Left and Down Arrows - if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) { + const spatialNavOptions = this.options_.playerOptions.spatialNavigation; + const spatialNavEnabled = spatialNavOptions && spatialNavOptions.enabled; + const horizontalSeek = spatialNavOptions && spatialNavOptions.horizontalSeek; + + if (spatialNavEnabled) { + if ((horizontalSeek && keycode.isEventKey(event, 'Left')) || + (!horizontalSeek && keycode.isEventKey(event, 'Down'))) { + event.preventDefault(); + event.stopPropagation(); + this.stepBack(); + } else if ((horizontalSeek && keycode.isEventKey(event, 'Right')) || + (!horizontalSeek && keycode.isEventKey(event, 'Up'))) { + event.preventDefault(); + event.stopPropagation(); + this.stepForward(); + } else { + super.handleKeyDown(event); + } + + // Left and Down Arrows + } else if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) { event.preventDefault(); event.stopPropagation(); this.stepBack(); - // Up and Right Arrows + // Up and Right Arrows } else if (keycode.isEventKey(event, 'Right') || keycode.isEventKey(event, 'Up')) { event.preventDefault(); event.stopPropagation(); diff --git a/src/js/spatial-navigation.js b/src/js/spatial-navigation.js new file mode 100644 index 0000000000..2db104c382 --- /dev/null +++ b/src/js/spatial-navigation.js @@ -0,0 +1,553 @@ +/** + * @file spatial-navigation.js + */ +import EventTarget from './event-target'; +import keycode from 'keycode'; +import SpatialNavKeyCodes from './utils/spatial-navigation-key-codes'; + +// The number of seconds the `step*` functions move the timeline. +const STEP_SECONDS = 5; + +/** + * Spatial Navigation in Video.js enhances user experience and accessibility on smartTV devices, + * enabling seamless navigation through interactive elements within the player using remote control arrow keys. + * This functionality allows users to effortlessly navigate through focusable components. + * + * @extends EventTarget + */ +class SpatialNavigation extends EventTarget { + + /** + * Constructs a SpatialNavigation instance with initial settings. + * Sets up the player instance, and prepares the spatial navigation system. + * + * @class + * @param {Object} player - The Video.js player instance to which the spatial navigation is attached. + */ + constructor(player) { + super(); + this.player_ = player; + this.focusableComponents = []; + this.isListening_ = false; + this.isPaused_ = false; + this.onKeyDown_ = this.onKeyDown_.bind(this); + this.lastFocusedComponent_ = null; + } + + /** + * Starts the spatial navigation by adding a keydown event listener to the video container. + * This method ensures that the event listener is added only once. + */ + start() { + // If the listener is already active, exit early. + if (this.isListening_) { + return; + } + + // Add the event listener since the listener is not yet active. + this.player_.on('keydown', this.onKeyDown_); + this.player_.on('modalKeydown', this.onKeyDown_); + // Listen for source change events + this.player_.on('loadedmetadata', () => { + this.focus(this.updateFocusableComponents()[0]); + }); + this.player_.on('modalclose', () => { + this.refocusComponent(); + }); + this.player_.on('focusin', this.handlePlayerFocus_.bind(this)); + this.player_.on('focusout', this.handlePlayerBlur_.bind(this)); + this.isListening_ = true; + } + + /** + * Stops the spatial navigation by removing the keydown event listener from the video container. + * Also sets the `isListening_` flag to false. + */ + stop() { + this.player_.off('keydown', this.onKeyDown_); + this.isListening_ = false; + } + + /** + * Responds to keydown events for spatial navigation and media control. + * + * Determines if spatial navigation or media control is active and handles key inputs accordingly. + * + * @param {KeyboardEvent} event - The keydown event to be handled. + */ + onKeyDown_(event) { + // Determine if the event is a custom modalKeydown event + const actualEvent = event.originalEvent ? event.originalEvent : event; + + if (keycode.isEventKey(actualEvent, 'left') || keycode.isEventKey(actualEvent, 'up') || + keycode.isEventKey(actualEvent, 'right') || keycode.isEventKey(actualEvent, 'down')) { + // Handle directional navigation + if (this.isPaused_) { + return; + } + actualEvent.preventDefault(); + const direction = keycode(actualEvent); + + this.move(direction); + } else if (SpatialNavKeyCodes.isEventKey(actualEvent, 'play') || SpatialNavKeyCodes.isEventKey(actualEvent, 'pause') || + SpatialNavKeyCodes.isEventKey(actualEvent, 'ff') || SpatialNavKeyCodes.isEventKey(actualEvent, 'rw')) { + // Handle media actions + actualEvent.preventDefault(); + const action = SpatialNavKeyCodes.getEventName(actualEvent); + + this.performMediaAction_(action); + } else if (SpatialNavKeyCodes.isEventKey(actualEvent, 'Back') && event.target && event.target.closeable()) { + actualEvent.preventDefault(); + event.target.close(); + } + } + + /** + * Performs media control actions based on the given key input. + * + * Controls the playback and seeking functionalities of the media player. + * + * @param {string} key - The key representing the media action to be performed. + * Accepted keys: 'play', 'pause', 'ff' (fast-forward), 'rw' (rewind). + */ + performMediaAction_(key) { + if (this.player_) { + switch (key) { + case 'play': + if (this.player_.paused()) { + this.player_.play(); + } + break; + case 'pause': + if (!this.player_.paused()) { + this.player_.pause(); + } + break; + case 'ff': + this.userSeek_(this.player_.currentTime() + STEP_SECONDS); + break; + case 'rw': + this.userSeek_(this.player_.currentTime() - STEP_SECONDS); + break; + default: + break; + } + } + } + + /** + * Prevent liveThreshold from causing seeks to seem like they + * are not happening from a user perspective. + * + * @param {number} ct + * current time to seek to + */ + userSeek_(ct) { + if (this.player_.liveTracker && this.player_.liveTracker.isLive()) { + this.player_.liveTracker.nextSeekedFromUser(); + } + + this.player_.currentTime(ct); + } + + /** + * Pauses the spatial navigation functionality. + * This method sets a flag that can be used to temporarily disable the navigation logic. + */ + pause() { + this.isPaused_ = true; + } + + /** + * Resumes the spatial navigation functionality if it has been paused. + * This method resets the pause flag, re-enabling the navigation logic. + */ + resume() { + this.isPaused_ = false; + } + + /** + * Handles Player Blur. + * + * @param {string|Event|Object} event + * The name of the event, an `Event`, or an object with a key of type set to + * an event name. + * + * Calls for handling of the Player Blur if: + * *The next focused element is not a child of current focused element & + * The next focused element is not a child of the Player. + * *There is no next focused element + */ + handlePlayerBlur_(event) { + const nextFocusedElement = event.relatedTarget; + let isChildrenOfPlayer = null; + const currentComponent = this.getCurrentComponent(event.target); + + if (nextFocusedElement) { + isChildrenOfPlayer = Boolean(nextFocusedElement.closest('.video-js')); + + // If nextFocusedElement is the 'TextTrackSettings' component + if (nextFocusedElement.classList.contains('vjs-text-track-settings') && !this.isPaused_) { + this.searchForTrackSelect(); + } + } + + if (!(event.currentTarget.contains(event.relatedTarget)) && !isChildrenOfPlayer || !nextFocusedElement) { + if (currentComponent.name() === 'CloseButton') { + this.refocusComponent(); + } else { + this.pause(); + + if (currentComponent && currentComponent.el()) { + // Store last focused component + this.lastFocusedComponent_ = currentComponent; + } + } + } + } + + /** + * Handles the Player focus event. + * + * Calls for handling of the Player Focus if current element is focusable. + */ + handlePlayerFocus_() { + if (this.getCurrentComponent() && this.getCurrentComponent().getIsFocusable()) { + this.resume(); + } + } + + /** + * Gets a set of focusable components. + * + * @return {Array} + * Returns an array of focusable components. + */ + updateFocusableComponents() { + const player = this.player_; + const focusableComponents = []; + + /** + * Searches for children candidates. + * + * Pushes Components to array of 'focusableComponents'. + * Calls itself if there is children elements inside iterated component. + * + * @param {Array} componentsArray - The array of components to search for focusable children. + */ + function searchForChildrenCandidates(componentsArray) { + for (const i of componentsArray) { + if (i.hasOwnProperty('el_') && i.getIsFocusable() && i.getIsAvailableToBeFocused(i.el())) { + focusableComponents.push(i); + } + if (i.hasOwnProperty('children_') && i.children_.length > 0) { + searchForChildrenCandidates(i.children_); + } + } + } + + // Iterate inside all children components of the player. + player.children_.forEach((value) => { + if (value.hasOwnProperty('el_')) { + // If component has required functions 'getIsFocusable' & 'getIsAvailableToBeFocused', is focusable & available to be focused. + if (value.getIsFocusable && value.getIsAvailableToBeFocused && value.getIsFocusable() && value.getIsAvailableToBeFocused(value.el())) { + focusableComponents.push(value); + return; + // If component has posible children components as candidates. + } else if (value.hasOwnProperty('children_') && value.children_.length > 0) { + searchForChildrenCandidates(value.children_); + // If component has posible item components as candidates. + } else if (value.hasOwnProperty('items') && value.items.length > 0) { + searchForChildrenCandidates(value.items); + // If there is a suitable child element within the component's DOM element. + } else if (this.findSuitableDOMChild(value)) { + focusableComponents.push(value); + } + } + }); + + this.focusableComponents = focusableComponents; + return this.focusableComponents; + } + + /** + * Finds a suitable child element within the provided component's DOM element. + * + * @param {Object} component - The component containing the DOM element to search within. + * @return {HTMLElement|null} Returns the suitable child element if found, or null if not found. + */ + findSuitableDOMChild(component) { + /** + * Recursively searches for a suitable child node that can be focused within a given component. + * It first checks if the provided node itself can be focused according to the component's + * `getIsFocusable` and `getIsAvailableToBeFocused` methods. If not, it recursively searches + * through the node's children to find a suitable child node that meets the focusability criteria. + * + * @param {HTMLElement} node - The DOM node to start the search from. + * @return {HTMLElement|null} The first child node that is focusable and available to be focused, + * or `null` if no suitable child is found. + */ + function searchForSuitableChild(node) { + if (component.getIsFocusable() && component.getIsAvailableToBeFocused(node)) { + return node; + } + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const suitableChild = searchForSuitableChild(child); + + if (suitableChild) { + return suitableChild; + } + } + + return null; + } + + return searchForSuitableChild(component.el()); + } + + /** + * Gets the currently focused component from the list of focusable components. + * If a target element is provided, it uses that element to find the corresponding + * component. If no target is provided, it defaults to using the document's currently + * active element. + * + * @param {HTMLElement} [target] - The DOM element to check against the focusable components. + * If not provided, `document.activeElement` is used. + * @return {Component|null} - Returns the focused component if found among the focusable components, + * otherwise returns null if no matching component is found. + */ + getCurrentComponent(target) { + this.updateFocusableComponents(); + // eslint-disable-next-line + const curComp = target || document.activeElement; + if (this.focusableComponents.length) { + for (const i of this.focusableComponents) { + // If component Node is equal to the current active element. + if (i.el() === curComp) { + return i; + } + } + } + } + + /** + * Adds a component to the array of focusable components. + * + * @param {Component} component + * The `Component` to be added. + */ + add(component) { + const focusableComponents = [...this.focusableComponents]; + + if (component.hasOwnProperty('el_') && component.getIsFocusable() && component.getIsAvailableToBeFocused(component.el())) { + focusableComponents.push(component); + } + + this.focusableComponents = focusableComponents; + // Trigger the notification manually + this.trigger({type: 'focusableComponentsChanged', focusableComponents: this.focusableComponents}); + } + + /** + * Removes component from the array of focusable components. + * + * @param {Component} component - The component to be removed from the focusable components array. + */ + remove(component) { + for (let i = 0; i < this.focusableComponents.length; i++) { + if (this.focusableComponents[i].name() === component.name()) { + this.focusableComponents.splice(i, 1); + // Trigger the notification manually + this.trigger({type: 'focusableComponentsChanged', focusableComponents: this.focusableComponents}); + return; + } + } + } + + /** + * Clears array of focusable components. + */ + clear() { + // Check if the array is already empty to avoid unnecessary event triggering + if (this.focusableComponents.length > 0) { + // Clear the array + this.focusableComponents = []; + + // Trigger the notification manually + this.trigger({type: 'focusableComponentsChanged', focusableComponents: this.focusableComponents}); + } + } + + /** + * Navigates to the next focusable component based on the specified direction. + * + * @param {string} direction 'up', 'down', 'left', 'right' + */ + move(direction) { + const currentFocusedComponent = this.getCurrentComponent(); + + if (!currentFocusedComponent) { + return; + } + + const currentPositions = currentFocusedComponent.getPositions(); + const candidates = this.focusableComponents.filter(component => + component !== currentFocusedComponent && + this.isInDirection_(currentPositions.boundingClientRect, component.getPositions().boundingClientRect, direction)); + + const bestCandidate = this.findBestCandidate_(currentPositions.center, candidates, direction); + + if (bestCandidate) { + this.focus(bestCandidate); + } else { + this.trigger({type: 'endOfFocusableComponents', direction, focusedComponent: currentFocusedComponent}); + } + } + + /** + * Finds the best candidate on the current center position, + * the list of candidates, and the specified navigation direction. + * + * @param {Object} currentCenter The center position of the current focused component element. + * @param {Array} candidates An array of candidate components to receive focus. + * @param {string} direction The direction of navigation ('up', 'down', 'left', 'right'). + * @return {Object|null} The component that is the best candidate for receiving focus. + */ + findBestCandidate_(currentCenter, candidates, direction) { + let minDistance = Infinity; + let bestCandidate = null; + + for (const candidate of candidates) { + const candidateCenter = candidate.getPositions().center; + const distance = this.calculateDistance_(currentCenter, candidateCenter, direction); + + if (distance < minDistance) { + minDistance = distance; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + /** + * Determines if a target rectangle is in the specified navigation direction + * relative to a source rectangle. + * + * @param {Object} srcRect The bounding rectangle of the source element. + * @param {Object} targetRect The bounding rectangle of the target element. + * @param {string} direction The navigation direction ('up', 'down', 'left', 'right'). + * @return {boolean} True if the target is in the specified direction relative to the source. + */ + isInDirection_(srcRect, targetRect, direction) { + switch (direction) { + case 'right': + return targetRect.left >= srcRect.right; + case 'left': + return targetRect.right <= srcRect.left; + case 'down': + return targetRect.top >= srcRect.bottom; + case 'up': + return targetRect.bottom <= srcRect.top; + default: + return false; + } + } + + /** + * Focus the last focused component saved before blur on player. + */ + refocusComponent() { + if (this.lastFocusedComponent_) { + // If use is not active, set it to active. + if (!this.player_.userActive()) { + this.player_.userActive(true); + } + + this.updateFocusableComponents(); + + // Search inside array of 'focusableComponents' for a match of name of + // the last focused component. + for (let i = 0; i < this.focusableComponents.length; i++) { + if (this.focusableComponents[i].name() === this.lastFocusedComponent_.name()) { + this.focus(this.focusableComponents[i]); + return; + } + } + } else { + this.focus(this.updateFocusableComponents()[0]); + } + } + + /** + * Focuses on a given component. + * If the component is available to be focused, it focuses on the component. + * If not, it attempts to find a suitable DOM child within the component and focuses on it. + * + * @param {Component} component - The component to be focused. + */ + focus(component) { + if (component.getIsAvailableToBeFocused(component.el())) { + component.focus(); + } else if (this.findSuitableDOMChild(component)) { + this.findSuitableDOMChild(component).focus(); + } + } + + /** + * Calculates the distance between two points, adjusting the calculation based on + * the specified navigation direction. + * + * @param {Object} center1 The center point of the first element. + * @param {Object} center2 The center point of the second element. + * @param {string} direction The direction of navigation ('up', 'down', 'left', 'right'). + * @return {number} The calculated distance between the two centers. + */ + calculateDistance_(center1, center2, direction) { + const dx = Math.abs(center1.x - center2.x); + const dy = Math.abs(center1.y - center2.y); + + let distance; + + switch (direction) { + case 'right': + case 'left': + // Higher weight for vertical distance in horizontal navigation. + distance = dx + (dy * 100); + break; + case 'up': + // Strongly prioritize vertical proximity for UP navigation. + // Adjust the weight to ensure that elements directly above are favored. + distance = (dy * 2) + (dx * 0.5); + break; + case 'down': + // More balanced weight for vertical and horizontal distances. + // Adjust the weights here to find the best balance. + distance = (dy * 5) + dx; + break; + default: + distance = dx + dy; + } + + return distance; + } + + /** + * This gets called by 'handlePlayerBlur_' if 'spatialNavigation' is enabled. + * Searches for the first 'TextTrackSelect' inside of modal to focus. + */ + searchForTrackSelect() { + const spatialNavigation = this; + + for (const component of (spatialNavigation.updateFocusableComponents())) { + if (component.constructor.name === 'TextTrackSelect') { + spatialNavigation.focus(component); + break; + } + } + } +} + +export default SpatialNavigation; diff --git a/src/js/tracks/text-track-fieldset.js b/src/js/tracks/text-track-fieldset.js new file mode 100644 index 0000000000..f2ac5e3643 --- /dev/null +++ b/src/js/tracks/text-track-fieldset.js @@ -0,0 +1,129 @@ +import Component from '../component'; +import * as Dom from '../utils/dom'; +import * as Guid from '../utils/guid'; +import TextTrackSelect from './text-track-select'; + +/** + * Creates fieldset section of 'TextTrackSettings'. + * Manganes two versions of fieldsets, one for type of 'colors' + * & the other for 'font', Component adds diferent DOM elements + * to that fieldset depending on the type. + * + * @extends Component + */ +class TextTrackFieldset extends Component { + + /** + * Creates an instance of this class. + * + * @param { import('./player').default } player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param { import('../utils/dom').ContentDescriptor} [options.content=undefined] + * Provide customized content for this modal. + * + * @param {string} [options.legendId] + * A text with part of an string to create atribute of aria-labelledby. + * It passes to 'TextTrackSelect'. + * + * @param {string} [options.id] + * A text with part of an string to create atribute of aria-labelledby. + * It passes to 'TextTrackSelect'. + * + * @param {string} [options.legendText] + * A text to use as the text content of the legend element. + * + * @param {array} [options.selects] + * Array that contains the selects that are use to create 'selects' + * components. + * + * @param {array} [options.SelectOptions] + * Array that contains the value & textContent of for each of the + * options elements, it passes to 'TextTrackSelect'. + * + * @param {string} [options.type] + * Conditions if some DOM elements will be added to the fieldset + * component. + * + * @param {Object} [options.selectConfigs] + * Object with the following properties that are the selects configurations: + * backgroundColor, backgroundOpacity, color, edgeStyle, fontFamily, + * fontPercent, textOpacity, windowColor, windowOpacity. + * These properties are use to configure the 'TextTrackSelect' Component. + */ + constructor(player, options = {}) { + super(player, options); + + // Add Components & DOM Elements + const legendElement = Dom.createEl('legend', { + textContent: this.localize(this.options_.legendText), + id: this.options_.legendId + }); + + this.el().appendChild(legendElement); + + const selects = this.options_.selects; + + // Iterate array of selects to create 'selects' components + for (const i of selects) { + const selectConfig = this.options_.selectConfigs[i]; + const selectClassName = selectConfig.className; + const id = selectConfig.id.replace('%s', this.options_.id_); + let span = null; + const guid = `vjs_select_${Guid.newGUID()}`; + + // Conditionally create span to add on the component + if (this.options_.type === 'colors') { + span = Dom.createEl('span', { + className: selectClassName + }); + + const label = Dom.createEl('label', { + id, + className: 'vjs-label', + textContent: selectConfig.label + }); + + label.setAttribute('for', guid); + span.appendChild(label); + } + + const textTrackSelect = new TextTrackSelect(player, { + SelectOptions: selectConfig.options, + legendId: this.options_.legendId, + id: guid, + labelId: id + }); + + this.addChild(textTrackSelect); + + // Conditionally append to 'select' component to conditionally created span + if (this.options_.type === 'colors') { + span.appendChild(textTrackSelect.el()); + this.el().appendChild(span); + } + } + } + + /** + * Create the `TextTrackFieldset`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + createEl() { + const el = Dom.createEl('fieldset', { + // Prefixing classes of elements within a player with "vjs-" + // is a convention used in Video.js. + className: this.options_.className + }); + + return el; + } +} + +Component.registerComponent('TextTrackFieldset', TextTrackFieldset); +export default TextTrackFieldset; diff --git a/src/js/tracks/text-track-select.js b/src/js/tracks/text-track-select.js new file mode 100644 index 0000000000..75f3e3ba1e --- /dev/null +++ b/src/js/tracks/text-track-select.js @@ -0,0 +1,79 @@ +import Component from '../component'; +import * as Dom from '../utils/dom'; + +/** + * Creates DOM element of 'select' & its options. + * + * @extends Component + */ +class TextTrackSelect extends Component { + + /** + * Creates an instance of this class. + * + * @param { import('./player').default } player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param { import('../utils/dom').ContentDescriptor} [options.content=undefined] + * Provide customized content for this modal. + * + * @param {string} [options.legendId] + * A text with part of an string to create atribute of aria-labelledby. + * + * @param {string} [options.id] + * A text with part of an string to create atribute of aria-labelledby. + * + * @param {array} [options.SelectOptions] + * Array that contains the value & textContent of for each of the + * options elements. + */ + + constructor(player, options = {}) { + super(player, options); + + this.el_.setAttribute('aria-labelledby', this.selectLabelledbyIds); + } + + /** + * Create the `TextTrackSelect`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + createEl() { + this.selectLabelledbyIds = [this.options_.legendId, this.options_.labelId].join(' ').trim(); + + // Create select & inner options + const selectoptions = Dom.createEl( + 'select', + { + id: this.options_.id + }, + {}, + this.options_.SelectOptions.map((optionText) => { + const optionId = this.options_.labelId + '-' + optionText[1].replace(/\W+/g, ''); + + const option = Dom.createEl( + 'option', + { + id: optionId, + value: this.localize(optionText[0]), + textContent: optionText[1] + } + ); + + option.setAttribute('aria-labelledby', `${this.selectLabelledbyIds} ${optionId}`); + + return option; + }) + ); + + return selectoptions; + } +} + +Component.registerComponent('TextTrackSelect', TextTrackSelect); +export default TextTrackSelect; diff --git a/src/js/tracks/text-track-settings-colors.js b/src/js/tracks/text-track-settings-colors.js new file mode 100644 index 0000000000..8e4e910795 --- /dev/null +++ b/src/js/tracks/text-track-settings-colors.js @@ -0,0 +1,104 @@ +import Component from '../component'; +import * as Dom from '../utils/dom'; +import TextTrackFieldset from './text-track-fieldset'; + +/** + * The component 'TextTrackSettingsColors' displays a set of 'fieldsets' + * using the component 'TextTrackFieldset'. + * + * @extends Component + */ +class TextTrackSettingsColors extends Component { + + /** + * Creates an instance of this class. + * + * @param { import('./player').default } player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param { import('../utils/dom').ContentDescriptor} [options.content=undefined] + * Provide customized content for this modal. + * + * @param {Array} [options.fieldSets] + * Array that contains the configurations for the selects. + * + * @param {Object} [options.selectConfigs] + * Object with the following properties that are the select confugations: + * backgroundColor, backgroundOpacity, color, edgeStyle, fontFamily, + * fontPercent, textOpacity, windowColor, windowOpacity. + * it passes to 'TextTrackFieldset'. + */ + constructor(player, options = {}) { + super(player, options); + + const id_ = this.options_.textTrackComponentid; + + // createElFgColor_ + const ElFgColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-text-legend-${id_}`, + legendText: this.localize('Text'), + className: 'vjs-fg vjs-track-setting', + selects: this.options_.fieldSets[0], + selectConfigs: this.options_.selectConfigs, + type: 'colors' + } + ); + + this.addChild(ElFgColorFieldset); + + // createElBgColor_ + const ElBgColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-background-${id_}`, + legendText: this.localize('Text Background'), + className: 'vjs-bg vjs-track-setting', + selects: this.options_.fieldSets[1], + selectConfigs: this.options_.selectConfigs, + type: 'colors' + } + ); + + this.addChild(ElBgColorFieldset); + + // createElWinColor_ + const ElWinColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-window-${id_}`, + legendText: this.localize('Caption Area Background'), + className: 'vjs-window vjs-track-setting', + selects: this.options_.fieldSets[2], + selectConfigs: this.options_.selectConfigs, + type: 'colors' + } + ); + + this.addChild(ElWinColorFieldset); + } + + /** + * Create the `TextTrackSettingsColors`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + createEl() { + const el = Dom.createEl('div', { + className: 'vjs-track-settings-colors' + }); + + return el; + } +} + +Component.registerComponent('TextTrackSettingsColors', TextTrackSettingsColors); +export default TextTrackSettingsColors; diff --git a/src/js/tracks/text-track-settings-controls.js b/src/js/tracks/text-track-settings-controls.js new file mode 100644 index 0000000000..b68c9f9588 --- /dev/null +++ b/src/js/tracks/text-track-settings-controls.js @@ -0,0 +1,60 @@ +import Component from '../component'; +import * as Dom from '../utils/dom'; +import Button from '../button'; + +/** + * Buttons of reset & done that modal 'TextTrackSettings' + * uses as part of its content. + * + * 'Reset': Resets all settings on 'TextTrackSettings'. + * 'Done': Closes 'TextTrackSettings' modal. + * + * @extends Component + */ +class TrackSettingsControls extends Component { + constructor(player, options = {}) { + super(player, options); + + // Create DOM elements + const defaultsDescription = this.localize('restore all settings to the default values'); + + const resetButton = new Button(player, { + controlText: defaultsDescription, + className: 'vjs-default-button' + }); + + resetButton.el().classList.remove('vjs-control', 'vjs-button'); + resetButton.el().textContent = this.localize('Reset'); + + this.addChild(resetButton); + + const doneButton = new Button(player, { + controlText: defaultsDescription, + className: 'vjs-done-button' + }); + + // Remove unrequired style classes + doneButton.el().classList.remove('vjs-control', 'vjs-button'); + doneButton.el().textContent = this.localize('Done'); + + this.addChild(doneButton); + } + + /** + * Create the `TrackSettingsControls`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + createEl() { + const el = Dom.createEl('div', { + className: 'vjs-track-settings-controls' + }); + + return el; + } + +} + +Component.registerComponent('TrackSettingsControls', TrackSettingsControls); +export default TrackSettingsControls; diff --git a/src/js/tracks/text-track-settings-font.js b/src/js/tracks/text-track-settings-font.js new file mode 100644 index 0000000000..9db7f43676 --- /dev/null +++ b/src/js/tracks/text-track-settings-font.js @@ -0,0 +1,101 @@ +import Component from '../component'; +import * as Dom from '../utils/dom'; +import TextTrackFieldset from './text-track-fieldset'; + +/** + * The component 'TextTrackSettingsFont' displays a set of 'fieldsets' + * using the component 'TextTrackFieldset'. + * + * @extends Component + */ +class TextTrackSettingsFont extends Component { + + /** + * Creates an instance of this class. + * + * @param { import('./player').default } player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param { import('../utils/dom').ContentDescriptor} [options.content=undefined] + * Provide customized content for this modal. + * + * @param {Array} [options.fieldSets] + * Array that contains the configurations for the selects. + * + * @param {Object} [options.selectConfigs] + * Object with the following properties that are the select confugations: + * backgroundColor, backgroundOpacity, color, edgeStyle, fontFamily, + * fontPercent, textOpacity, windowColor, windowOpacity. + * it passes to 'TextTrackFieldset'. + */ + constructor(player, options = {}) { + super(player, options); + + const id_ = this.options_.textTrackComponentid; + + const ElFgColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-font-size-${id_}`, + legendText: 'Font Size', + className: 'vjs-font-percent vjs-track-setting', + selects: this.options_.fieldSets[0], + selectConfigs: this.options_.selectConfigs, + type: 'font' + } + ); + + this.addChild(ElFgColorFieldset); + + const ElBgColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-background-${id_}`, + legendText: this.localize('Text Edge Style'), + className: 'vjs-edge-style vjs-track-setting', + selects: this.options_.fieldSets[1], + selectConfigs: this.options_.selectConfigs, + type: 'font' + } + ); + + this.addChild(ElBgColorFieldset); + + const ElWinColorFieldset = new TextTrackFieldset( + player, + { + id_, + legendId: `captions-font-family-${id_}`, + legendText: this.localize('Font Family'), + className: 'vjs-font-family vjs-track-setting', + selects: this.options_.fieldSets[2], + selectConfigs: this.options_.selectConfigs, + type: 'font' + } + ); + + this.addChild(ElWinColorFieldset); + } + + /** + * Create the `TextTrackSettingsFont`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + createEl() { + const el = Dom.createEl('div', { + className: 'vjs-track-settings-font' + }); + + return el; + } +} + +Component.registerComponent('TextTrackSettingsFont', TextTrackSettingsFont); +export default TextTrackSettingsFont; diff --git a/src/js/tracks/text-track-settings.js b/src/js/tracks/text-track-settings.js index 3859dbbfe8..2c9c63ce30 100644 --- a/src/js/tracks/text-track-settings.js +++ b/src/js/tracks/text-track-settings.js @@ -6,8 +6,10 @@ import Component from '../component'; import ModalDialog from '../modal-dialog'; import {createEl} from '../utils/dom'; import * as Obj from '../utils/obj'; -import * as Guid from '../utils/guid.js'; import log from '../utils/log'; +import TextTrackSettingsColors from './text-track-settings-colors'; +import TextTrackSettingsFont from './text-track-settings-font'; +import TrackSettingsControls from './text-track-settings-controls'; const LOCAL_STORAGE_KEY = 'vjs-text-track-settings'; @@ -49,7 +51,8 @@ const selectConfigs = { COLOR_YELLOW, COLOR_MAGENTA, COLOR_CYAN - ] + ], + className: 'vjs-bg-color' }, backgroundOpacity: { @@ -60,7 +63,8 @@ const selectConfigs = { OPACITY_OPAQUE, OPACITY_SEMI, OPACITY_TRANS - ] + ], + className: 'vjs-bg-opacity vjs-opacity' }, color: { @@ -76,7 +80,8 @@ const selectConfigs = { COLOR_YELLOW, COLOR_MAGENTA, COLOR_CYAN - ] + ], + className: 'vjs-text-color' }, edgeStyle: { @@ -133,14 +138,16 @@ const selectConfigs = { options: [ OPACITY_OPAQUE, OPACITY_SEMI - ] + ], + className: 'vjs-text-opacity vjs-opacity' }, // Options for this object are defined below. windowColor: { selector: '.vjs-window-color > select', id: 'captions-window-color-%s', - label: 'Color' + label: 'Color', + className: 'vjs-window-color' }, // Options for this object are defined below. @@ -152,7 +159,8 @@ const selectConfigs = { OPACITY_TRANS, OPACITY_SEMI, OPACITY_OPAQUE - ] + ], + className: 'vjs-window-opacity vjs-opacity' } }; @@ -254,12 +262,15 @@ class TextTrackSettings extends ModalDialog { options.temporary = false; super(player, options); + this.updateDisplay = this.updateDisplay.bind(this); // fill the modal and pretend we have opened it this.fill(); this.hasBeenOpened_ = this.hasBeenFilled_ = true; + this.renderModalComponents(player); + this.endDialog = createEl('p', { className: 'vjs-control-text', textContent: this.localize('End of dialog window.') @@ -273,6 +284,52 @@ class TextTrackSettings extends ModalDialog { this.options_.persistTextTrackSettings = this.options_.playerOptions.persistTextTrackSettings; } + this.bindFunctionsToSelectsAndButtons(); + + if (this.options_.persistTextTrackSettings) { + this.restoreSettings(); + } + } + + renderModalComponents(player) { + const textTrackSettingsColors = new TextTrackSettingsColors( + player, + { + textTrackComponentid: this.id_, + selectConfigs, + fieldSets: + [ + ['color', 'textOpacity'], + ['backgroundColor', 'backgroundOpacity'], + ['windowColor', 'windowOpacity'] + ] + } + ); + + this.addChild(textTrackSettingsColors); + + const textTrackSettingsFont = new TextTrackSettingsFont( + player, + { + textTrackComponentid: this.id_, + selectConfigs, + fieldSets: + [ + ['fontPercent'], + ['edgeStyle'], + ['fontFamily'] + ] + } + ); + + this.addChild(textTrackSettingsFont); + + const trackSettingsControls = new TrackSettingsControls(player); + + this.addChild(trackSettingsControls); + } + + bindFunctionsToSelectsAndButtons() { this.on(this.$('.vjs-done-button'), 'click', () => { this.saveSettings(); this.close(); @@ -286,10 +343,6 @@ class TextTrackSettings extends ModalDialog { Obj.each(selectConfigs, config => { this.on(this.$(config.selector), 'change', this.updateDisplay); }); - - if (this.options_.persistTextTrackSettings) { - this.restoreSettings(); - } } dispose() { @@ -298,201 +351,6 @@ class TextTrackSettings extends ModalDialog { super.dispose(); } - /** - * Create a ` - ]. - concat(config.options.map(o => { - const optionId = id + '-' + o[1].replace(/\W+/g, ''); - - return [ - `' - ].join(''); - })). - concat('').join(''); - } - - /** - * Create foreground color element for the component - * - * @return {string} - * An HTML string. - * - * @private - */ - createElFgColor_() { - const legendId = `captions-text-legend-${this.id_}`; - - return [ - '
', - ``, - this.localize('Text'), - '', - '', - this.createElSelect_('color', legendId), - '', - '', - this.createElSelect_('textOpacity', legendId), - '', - '
' - ].join(''); - } - - /** - * Create background color element for the component - * - * @return {string} - * An HTML string. - * - * @private - */ - createElBgColor_() { - const legendId = `captions-background-${this.id_}`; - - return [ - '
', - ``, - this.localize('Text Background'), - '', - '', - this.createElSelect_('backgroundColor', legendId), - '', - '', - this.createElSelect_('backgroundOpacity', legendId), - '', - '
' - ].join(''); - } - - /** - * Create window color element for the component - * - * @return {string} - * An HTML string. - * - * @private - */ - createElWinColor_() { - const legendId = `captions-window-${this.id_}`; - - return [ - '
', - ``, - this.localize('Caption Area Background'), - '', - '', - this.createElSelect_('windowColor', legendId), - '', - '', - this.createElSelect_('windowOpacity', legendId), - '', - '
' - ].join(''); - } - - /** - * Create color elements for the component - * - * @return {Element} - * The element that was created - * - * @private - */ - createElColors_() { - return createEl('div', { - className: 'vjs-track-settings-colors', - innerHTML: [ - this.createElFgColor_(), - this.createElBgColor_(), - this.createElWinColor_() - ].join('') - }); - } - - /** - * Create font elements for the component - * - * @return {Element} - * The element that was created. - * - * @private - */ - createElFont_() { - return createEl('div', { - className: 'vjs-track-settings-font', - innerHTML: [ - '
', - this.createElSelect_('fontPercent', '', 'legend'), - '
', - '
', - this.createElSelect_('edgeStyle', '', 'legend'), - '
', - '
', - this.createElSelect_('fontFamily', '', 'legend'), - '
' - ].join('') - }); - } - - /** - * Create controls for the component - * - * @return {Element} - * The element that was created. - * - * @private - */ - createElControls_() { - const defaultsDescription = this.localize('restore all settings to the default values'); - - return createEl('div', { - className: 'vjs-track-settings-controls', - innerHTML: [ - `', - `` - ].join('') - }); - } - - content() { - return [ - this.createElColors_(), - this.createElFont_(), - this.createElControls_() - ]; - } - label() { return this.localize('Caption Settings Dialog'); } @@ -595,30 +453,13 @@ class TextTrackSettings extends ModalDialog { } } - /** - * conditionally blur the element and refocus the captions button - * - * @private - */ - conditionalBlur_() { - this.previouslyActiveEl_ = null; - - const cb = this.player_.controlBar; - const subsCapsBtn = cb && cb.subsCapsButton; - const ccBtn = cb && cb.captionsButton; - - if (subsCapsBtn) { - subsCapsBtn.focus(); - } else if (ccBtn) { - ccBtn.focus(); - } - } - /** * Repopulate dialog with new localizations on languagechange */ handleLanguagechange() { this.fill(); + this.renderModalComponents(this.player_); + this.bindFunctionsToSelectsAndButtons(); } } diff --git a/src/js/utils/spatial-navigation-key-codes.js b/src/js/utils/spatial-navigation-key-codes.js new file mode 100644 index 0000000000..ad26206d3f --- /dev/null +++ b/src/js/utils/spatial-navigation-key-codes.js @@ -0,0 +1,47 @@ +// /** +// * @file spatial-navigation-keycode.js +// */ + +import * as browser from './browser.js'; + +// Determine the keycode for the 'back' key based on the platform +const backKeyCode = browser.IS_TIZEN ? 10009 : browser.IS_WEBOS ? 461 : 8; + +const SpatialNavKeyCodes = { + codes: { + play: 415, + pause: 19, + ff: 417, + rw: 412, + back: backKeyCode + }, + names: { + 415: 'play', + 19: 'pause', + 417: 'ff', + 412: 'rw', + [backKeyCode]: 'back' + }, + + isEventKey(event, keyName) { + keyName = keyName.toLowerCase(); + + if (this.names[event.keyCode] && this.names[event.keyCode] === keyName) { + return true; + } + return false; + }, + + getEventName(event) { + if (this.names[event.keyCode]) { + return this.names[event.keyCode]; + } else if (this.codes[event.code]) { + const code = this.codes[event.code]; + + return this.names[code]; + } + return null; + } +}; + +export default SpatialNavKeyCodes; diff --git a/test/unit/component.test.js b/test/unit/component.test.js index 535bc81263..903dc8f425 100644 --- a/test/unit/component.test.js +++ b/test/unit/component.test.js @@ -1526,3 +1526,115 @@ QUnit.test('a component\'s el can be replaced on dispose', function(assert) { assert.strictEqual(Array.from(this.player.el_.childNodes).indexOf(replacementEl), prevIndex, 'replacement was inserted at same position'); }); + +QUnit.test('should be able to call `getPositions()` from a component', function(assert) { + const player = TestHelpers.makePlayer({}); + + const appendSpy = sinon.spy(player.controlBar, 'getPositions'); + + player.controlBar.getPositions(); + + assert.expect(1); + assert.ok(appendSpy.calledOnce, '`handleBlur` has been called'); + player.dispose(); +}); + +QUnit.test('getPositions() returns properties of `boundingClientRect` & `center` from elements that support it', function(assert) { + const player = TestHelpers.makePlayer({ + spatialNavigation: { + enabled: true + } + }); + + assert.expect(4); + assert.ok(player.controlBar.getPositions().boundingClientRect, '`boundingClientRect` present in `controlBar`'); + assert.ok(player.controlBar.getPositions().center, '`center` present in `controlBar`'); + assert.ok(typeof player.controlBar.getPositions().boundingClientRect === 'object', '`boundingClientRect` is an object'); + assert.ok(typeof player.controlBar.getPositions().center === 'object', '`center` is an object`'); + + player.dispose(); +}); + +QUnit.test('getPositions() properties should not be empty', function(assert) { + const player = TestHelpers.makePlayer({ + controls: true, + bigPlayButton: true, + spatialNavigation: { enabled: true } + }); + + function isEmpty(obj) { + return Object.keys(obj).length === 0; + } + + let hasEmptyProperties = false; + const getPositionsProps = player.bigPlayButton.getPositions(); + + for (const property in getPositionsProps) { + const getPositionsProp = getPositionsProps[property]; + + for (const innerProperty in getPositionsProp) { + if (isEmpty(innerProperty)) { + hasEmptyProperties = true; + } + } + } + + assert.expect(1); + assert.ok(!hasEmptyProperties, '`getPositions()` properties are not empty'); + + player.dispose(); +}); + +QUnit.test('component keydown event propagation does not stop if spatial navigation is active', function(assert) { + // Ensure each test starts with a player that has spatial navigation enabled + this.player = TestHelpers.makePlayer({ + controls: true, + bigPlayButton: true, + spatialNavigation: { enabled: true } + }); + + // Directly reference the instantiated SpatialNavigation from the player + this.spatialNav = this.player.spatialNavigation; + + this.spatialNav.start(); + const handlerSpy = sinon.spy(this.player, 'handleKeyDown'); + + // Create and dispatch a mock keydown event. + const event = new KeyboardEvent('keydown', { // eslint-disable-line no-undef + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + location: 2, + repeat: true + }); + + this.player.bigPlayButton.handleKeyDown(event); + assert.ok(handlerSpy.calledOnce); + + handlerSpy.restore(); + this.player.dispose(); +}); + +QUnit.test('Should be able to call `getIsAvailableToBeFocused()` even without passing an HTML element', function(assert) { + // Ensure each test starts with a player that has spatial navigation enabled + this.player = TestHelpers.makePlayer({ + controls: true, + bigPlayButton: true, + spatialNavigation: { enabled: true } + }); + + // Directly reference the instantiated SpatialNavigation from the player + this.spatialNav = this.player.spatialNavigation; + + const component = this.player.getChild('bigPlayButton'); + const focusSpy = sinon.spy(component, 'getIsAvailableToBeFocused'); + + component.getIsAvailableToBeFocused(component.el()); + component.getIsAvailableToBeFocused(); + + assert.ok(focusSpy.getCalls().length === 2, 'focus method called on component'); + + // Clean up + focusSpy.restore(); + this.player.dispose(); +}); diff --git a/test/unit/spatial-navigation.test.js b/test/unit/spatial-navigation.test.js new file mode 100644 index 0000000000..9c0fbb0421 --- /dev/null +++ b/test/unit/spatial-navigation.test.js @@ -0,0 +1,493 @@ +/* eslint-env qunit */ +import SpatialNavigation from '../../src/js/spatial-navigation.js'; +import SpatialNavigationKeyCodes from '../../src/js/utils/spatial-navigation-key-codes'; +import TestHelpers from './test-helpers.js'; +import sinon from 'sinon'; +import document from 'global/document'; +import TextTrackSelect from '../../src/js/tracks/text-track-select'; + +QUnit.module('SpatialNavigation', { + beforeEach() { + this.clock = sinon.useFakeTimers(); + // Ensure each test starts with a player that has spatial navigation enabled + this.player = TestHelpers.makePlayer({ + controls: true, + bigPlayButton: true, + spatialNavigation: { enabled: true } + }); + // Directly reference the instantiated SpatialNavigation from the player + this.spatialNav = this.player.spatialNavigation; + }, + afterEach() { + if (this.spatialNav && this.spatialNav.isListening_) { + this.spatialNav.stop(); + } + this.player.dispose(); + this.clock.restore(); + } +}); + +QUnit.test('Initialization sets up initial properties', function(assert) { + assert.ok(this.spatialNav instanceof SpatialNavigation, 'Instance of SpatialNavigation'); + assert.deepEqual(this.spatialNav.focusableComponents, [], 'Initial focusableComponents is an empty array'); + assert.notOk(this.spatialNav.isListening_, 'isListening_ is initially false'); + assert.notOk(this.spatialNav.isPaused_, 'isPaused_ is initially false'); +}); + +QUnit.test('start method initializes event listeners', function(assert) { + const onSpy = sinon.spy(this.player, 'on'); + + this.spatialNav.start(); + + // Check if event listeners are added + assert.ok(onSpy.calledWith('keydown'), 'keydown event listener added'); + assert.ok(onSpy.calledWith('loadedmetadata'), 'loadedmetadata event listener added'); + assert.ok(onSpy.calledWith('modalKeydown'), 'modalKeydown event listener added'); + assert.ok(onSpy.calledWith('modalclose'), 'modalclose event listener added'); + + // Additionally, check if isListening_ flag is set + assert.ok(this.spatialNav.isListening_, 'isListening_ flag is set'); + + onSpy.restore(); +}); + +QUnit.test('stop method removes event listeners', function(assert) { + const offSpy = sinon.spy(this.player, 'off'); + + this.spatialNav.start(); + this.spatialNav.stop(); + assert.ok(offSpy.calledWith('keydown'), 'keydown event listener removed'); + assert.notOk(this.spatialNav.isListening_, 'isListening_ flag is unset'); + offSpy.restore(); +}); + +QUnit.test('onKeyDown_ handles navigation keys', function(assert) { + // Ensure onKeyDown_ is bound correctly. + assert.equal(typeof this.spatialNav.onKeyDown_, 'function', 'onKeyDown_ should be a function'); + assert.equal(this.spatialNav.onKeyDown_.hasOwnProperty('prototype'), false, 'onKeyDown_ should be bound to the instance'); + + // Prepare a spy for the move method to track its calls. + const moveSpy = sinon.spy(this.spatialNav, 'move'); + + // Create and dispatch a mock keydown event. + const event = new KeyboardEvent('keydown', { // eslint-disable-line no-undef + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39 + }); + + // Directly invoke the onKeyDown_ handler to simulate receiving the event. + this.spatialNav.onKeyDown_(event); + + // Assert that move was called correctly. + assert.ok(moveSpy.calledOnce, 'move method should be called once on keydown event'); + assert.ok(moveSpy.calledWith('right'), 'move method should be called with "right" argument'); + + // Restore the spy to clean up. + moveSpy.restore(); +}); + +QUnit.test('onKeyDown_ handles media keys', function(assert) { + const performMediaActionSpy = sinon.spy(this.spatialNav, 'performMediaAction_'); + + // Create a mock event for the 'play' key, using the hardcoded keyCode 415. + const event = new KeyboardEvent('keydown', { // eslint-disable-line no-undef + keyCode: 415 + }); + + // Directly call the onKeyDown_ handler. + this.spatialNav.onKeyDown_(event); + + // Assert that the performMediaAction_ method was called. + assert.ok(performMediaActionSpy.calledOnce, 'performMediaAction_ method should be called once for media play key'); + assert.ok(performMediaActionSpy.calledWith('play'), 'performMediaAction_ should be called with "play"'); + + performMediaActionSpy.restore(); +}); + +QUnit.test('onKeyDown_ handles Back key when target is closeable', function(assert) { + // Create a spy for the close method. + const closeSpy = sinon.spy(); + + // Create a spy for the preventDefault method. + const preventDefaultSpy = sinon.spy(); + + // Create a mock event target that is closeable. + const closeableTarget = { + close: closeSpy, + closeable: () => true + }; + + // Create a mock event for the 'Back' key, including a properly mocked originalEvent. + const event = { + preventDefault: preventDefaultSpy, + target: closeableTarget, + originalEvent: { + keyCode: SpatialNavigationKeyCodes.BACK, + preventDefault: preventDefaultSpy + } + }; + + // Stub the SpatialNavigationKeyCodes.isEventKey to return true when the 'Back' key is pressed. + sinon.stub(SpatialNavigationKeyCodes, 'isEventKey').callsFake((evt, keyName) => keyName === 'Back'); + + // Call the onKeyDown_ method with the mock event. + this.spatialNav.onKeyDown_(event); + + // Asserts + assert.ok(SpatialNavigationKeyCodes.isEventKey.calledWith(event.originalEvent, 'Back'), 'isEventKey should be called with Back'); + assert.ok(preventDefaultSpy.calledOnce, 'preventDefault should be called once'); + assert.ok(closeSpy.calledOnce, 'close method should be called on the target'); + + // Restore stubs + SpatialNavigationKeyCodes.isEventKey.restore(); +}); + +QUnit.test('performMediaAction_ executes play', function(assert) { + const playSpy = sinon.spy(this.player, 'play'); + + this.spatialNav.performMediaAction_('play'); + + assert.ok(playSpy.calledOnce, 'play method should be called once for "play" action'); + + playSpy.restore(); +}); + +QUnit.test('performMediaAction_ executes pause', function(assert) { + const pauseSpy = sinon.spy(this.player, 'pause'); + + sinon.stub(this.player, 'paused').returns(false); + + this.spatialNav.performMediaAction_('pause'); + + assert.ok(pauseSpy.calledOnce, 'pause method should be called once for "pause" action'); + + pauseSpy.restore(); +}); + +QUnit.test('performMediaAction_ executes fast forward', function(assert) { + const userSeekSpy = sinon.spy(this.spatialNav, 'userSeek_'); + const STEP_SECONDS = 5; + const initialTime = 30; + + this.player.currentTime = () => initialTime; + + this.spatialNav.performMediaAction_('ff'); + + const expectedNewTime = initialTime + STEP_SECONDS; + + assert.ok(userSeekSpy.calledOnce, 'userSeek_ method should be called once for "fast forward" action'); + assert.ok(userSeekSpy.calledWith(expectedNewTime), `userSeek_ method should be called with correct time offset: expected ${expectedNewTime}, got ${userSeekSpy.firstCall.args[0]}`); + + userSeekSpy.restore(); +}); + +QUnit.test('performMediaAction_ executes rewind', function(assert) { + const userSeekSpy = sinon.spy(this.spatialNav, 'userSeek_'); + const STEP_SECONDS = 5; + const initialTime = 30; + + this.player.currentTime = () => initialTime; + + this.spatialNav.performMediaAction_('rw'); + + const expectedNewTime = initialTime - STEP_SECONDS; + + assert.ok(userSeekSpy.calledOnce, 'userSeek_ method should be called once for "rewind" action'); + assert.ok(userSeekSpy.calledWith(expectedNewTime), `userSeek_ method should be called with correct time offset: expected ${expectedNewTime}, got ${userSeekSpy.firstCall.args[0]}`); + + userSeekSpy.restore(); +}); + +QUnit.test('focus method sets focus on a player component', function(assert) { + this.spatialNav.start(); + + const component = this.player.getChild('bigPlayButton'); + + assert.ok(component, 'The target component exists.'); + + // Mock getIsAvailableToBeFocused to always return true + component.getIsAvailableToBeFocused = () => true; + + // Spy on the focus method to check if it's called + const focusSpy = sinon.spy(component, 'focus'); + + this.spatialNav.focus(component); + + assert.ok(focusSpy.calledOnce, 'focus method called on component'); + + // Clean up + focusSpy.restore(); +}); + +QUnit.test('refocusComponent method refocuses the last focused component after losing focus', function(assert) { + this.spatialNav.start(); + + // Get the bigPlayButton component from the player + const bigPlayButton = this.player.getChild('bigPlayButton'); + + // Mock getIsAvailableToBeFocused to always return true for testing + bigPlayButton.getIsAvailableToBeFocused = () => true; + + // Focus the bigPlayButton and set it as the last focused component + this.spatialNav.focus(bigPlayButton); + + // Simulate losing focus + bigPlayButton.el().blur(); + + // Call refocusComponent to attempt to refocus the last focused component + this.spatialNav.refocusComponent(); + + // Check if the bigPlayButton is focused again + assert.strictEqual(this.spatialNav.lastFocusedComponent_, bigPlayButton, 'lastFocusedComponent_ should be set to the blurred component'); +}); + +QUnit.test('move method changes focus to the right component', function(assert) { + this.spatialNav.start(); + + const rightComponent = { + name: () => 'rightComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 300, y: 100 }, boundingClientRect: { top: 0, left: 300, bottom: 200, right: 400 } }), + getIsAvailableToBeFocused: () => true + }; + + const currentComponent = { + name: () => 'currentComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 0, left: 100, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true + }; + + this.spatialNav.focusableComponents = [currentComponent, rightComponent]; + this.spatialNav.getCurrentComponent = () => currentComponent; + + this.spatialNav.move('right'); + + assert.ok(rightComponent.focus.calledOnce, 'Focus should move to the right component'); + assert.notOk(currentComponent.focus.called, 'Focus should not remain on the current component'); +}); + +QUnit.test('move method changes focus to the left component', function(assert) { + this.spatialNav.start(); + + const leftComponent = { + name: () => 'leftComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 0, y: 100 }, boundingClientRect: { top: 0, left: 0, bottom: 200, right: 100 } }), + getIsAvailableToBeFocused: () => true + }; + + const currentComponent = { + name: () => 'currentComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 200, y: 100 }, boundingClientRect: { top: 0, left: 200, bottom: 200, right: 300 } }), + getIsAvailableToBeFocused: () => true + }; + + this.spatialNav.focusableComponents = [leftComponent, currentComponent]; + this.spatialNav.getCurrentComponent = () => currentComponent; + + this.spatialNav.move('left'); + + assert.ok(leftComponent.focus.calledOnce, 'Focus should move to the left component'); + assert.notOk(currentComponent.focus.called, 'Focus should not remain on the current component'); +}); + +QUnit.test('move method changes focus to the above component', function(assert) { + this.spatialNav.start(); + + const aboveComponent = { + name: () => 'aboveComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 0 }, boundingClientRect: { top: 0, left: 0, bottom: 100, right: 200 } }), + getIsAvailableToBeFocused: () => true + }; + + const currentComponent = { + name: () => 'currentComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 200 }, boundingClientRect: { top: 200, left: 0, bottom: 300, right: 200 } }), + getIsAvailableToBeFocused: () => true + }; + + this.spatialNav.focusableComponents = [aboveComponent, currentComponent]; + this.spatialNav.getCurrentComponent = () => currentComponent; + + this.spatialNav.move('up'); + + assert.ok(aboveComponent.focus.calledOnce, 'Focus should move to the above component'); + assert.notOk(currentComponent.focus.called, 'Focus should not remain on the current component'); +}); + +QUnit.test('move method changes focus to the below component', function(assert) { + this.spatialNav.start(); + + const belowComponent = { + name: () => 'belowComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 300 }, boundingClientRect: { top: 300, left: 0, bottom: 400, right: 200 } }), + getIsAvailableToBeFocused: () => true + }; + + const currentComponent = { + name: () => 'currentComponent', + el: () => document.createElement('div'), + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 0, left: 0, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true + }; + + this.spatialNav.focusableComponents = [belowComponent, currentComponent]; + this.spatialNav.getCurrentComponent = () => currentComponent; + + this.spatialNav.move('down'); + + assert.ok(belowComponent.focus.calledOnce, 'Focus should move to the below component'); + assert.notOk(currentComponent.focus.called, 'Focus should not remain on the current component'); +}); + +QUnit.test('getCurrentComponent method returns the current focused component', function(assert) { + this.spatialNav.start(); + + // Get the bigPlayButton component from the player + const bigPlayButton = this.player.getChild('bigPlayButton'); + + // Mock getIsAvailableToBeFocused to always return true for testing + bigPlayButton.getIsAvailableToBeFocused = () => true; + + // Focus the bigPlayButton + this.spatialNav.focus(bigPlayButton); + + // Call getCurrentComponent to get the current focused component + const currentComponent = this.spatialNav.getCurrentComponent(); + + // Check if the currentComponent is the bigPlayButton + assert.strictEqual(currentComponent, bigPlayButton, 'getCurrentComponent should return the focused component'); +}); + +QUnit.test('add method adds a new focusable component', function(assert) { + this.spatialNav.start(); + + // Create a mock component with an 'el_' property and 'el' method + const newComponent = { + name: () => 'newComponent', + el_: document.createElement('div'), + el() { + return this.el_; + }, + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 100, left: 100, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true, + getIsFocusable: () => true + }; + + // Add the new component + this.spatialNav.add(newComponent); + + // Check if the new component is added to the list of focusable components + assert.strictEqual(this.spatialNav.focusableComponents.includes(newComponent), true, 'New component should be added'); +}); + +QUnit.test('remove method removes a focusable component', function(assert) { + this.spatialNav.start(); + + // Create a mock component + const componentToRemove = { + name: () => 'componentToRemove', + el_: document.createElement('div'), + el() { + return this.el_; + }, + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 100, left: 100, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true, + getIsFocusable: () => true + }; + + // Add the component to be removed + this.spatialNav.add(componentToRemove); + + // Remove the component + this.spatialNav.remove(componentToRemove); + + // Check if the component is removed from the list of focusable components + assert.strictEqual(this.spatialNav.focusableComponents.includes(componentToRemove), false, 'Component should be removed'); +}); + +QUnit.test('clear method removes all focusable components', function(assert) { + this.spatialNav.start(); + + // Create mock components + const component1 = { + name: () => 'component1', + el_: document.createElement('div'), + el() { + return this.el_; + }, + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 100, left: 100, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true, + getIsFocusable: () => true + }; + + const component2 = { + name: () => 'component2', + el_: document.createElement('div'), + el() { + return this.el_; + }, + focus: sinon.spy(), + getPositions: () => ({ center: { x: 100, y: 100 }, boundingClientRect: { top: 100, left: 100, bottom: 200, right: 200 } }), + getIsAvailableToBeFocused: () => true, + getIsFocusable: () => true + }; + + // Add the components + this.spatialNav.add(component1); + this.spatialNav.add(component2); + + // Clear all components + this.spatialNav.clear(); + + // Check if the focusableComponents array is empty after clearing + assert.strictEqual(this.spatialNav.focusableComponents.length, 0, 'All components should be cleared'); +}); + +QUnit.test('should call `searchForTrackSelect()` if spatial navigation is enabled on click event', function(assert) { + const element = document.createElement('div'); + + element.classList.add('vjs-text-track-settings'); + + const clickEvent = new MouseEvent('click', { // eslint-disable-line no-undef + view: this.window, + bubbles: true, + cancelable: true, + currentTarget: element + }); + + Object.defineProperty(clickEvent, 'relatedTarget', {writable: false, value: element}); + Object.defineProperty(clickEvent, 'currentTarget', {writable: false, value: element}); + + const trackSelectSpy = sinon.spy(this.spatialNav, 'searchForTrackSelect'); + + const textTrackSelectComponent = new TextTrackSelect(this.player, { + SelectOptions: ['Option 1', 'Option 2', 'Option 3'], + legendId: '1', + id: 1, + labelId: '1' + }); + + this.spatialNav.updateFocusableComponents = () => [textTrackSelectComponent]; + + this.spatialNav.handlePlayerBlur_(clickEvent); + + assert.ok(trackSelectSpy.calledOnce); +}); diff --git a/test/unit/tracks/text-track-select.test.js b/test/unit/tracks/text-track-select.test.js new file mode 100644 index 0000000000..1df96b2ca1 --- /dev/null +++ b/test/unit/tracks/text-track-select.test.js @@ -0,0 +1,25 @@ +/* eslint-env qunit */ +import TestHelpers from '../test-helpers.js'; + +const tracks = [{ + kind: 'captions', + label: 'test' +}]; + +QUnit.module('Text Track Select'); + +QUnit.test('should associate with