1
0
mirror of https://github.com/videojs/video.js.git synced 2025-01-02 06:32:07 +02:00

feat: implement spatial navigation (#8570)

* 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 <walterseymour15@gmail.com>

* 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 <carlosdeveloper9@gmail.com>
Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com>
Co-authored-by: Walter Seymour <walterseymour15@gmail.com>
Co-authored-by: Carlos Villasenor Castillo <cvillasenor@Carloss-MacBook-Pro.local>
This commit is contained in:
Borut Zizmond 2024-04-18 03:34:52 +02:00 committed by GitHub
parent 582c35f96b
commit 21b4a5225b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2124 additions and 234 deletions

View File

@ -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%;

View File

@ -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

View File

@ -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

View File

@ -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);
}

View File

@ -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.
*

View File

@ -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();

View File

@ -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();

View File

@ -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
};

View File

@ -308,14 +308,32 @@ class Slider extends Component {
* @listens keydown
*/
handleKeyDown(event) {
const spatialNavOptions = this.options_.playerOptions.spatialNavigation;
const spatialNavEnabled = spatialNavOptions && spatialNavOptions.enabled;
const horizontalSeek = spatialNavOptions && spatialNavOptions.horizontalSeek;
// Left and Down Arrows
if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) {
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();

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 <select> element with configured options.
*
* @param {string} key
* Configuration key to use during creation.
*
* @param {string} [legendId]
* Id of associated <legend>.
*
* @param {string} [type=label]
* Type of labelling element, `label` or `legend`
*
* @return {string}
* An HTML string.
*
* @private
*/
createElSelect_(key, legendId = '', type = 'label') {
const config = selectConfigs[key];
const id = config.id.replace('%s', this.id_);
const selectLabelledbyIds = [legendId, id].join(' ').trim();
const guid = `vjs_select_${Guid.newGUID()}`;
return [
`<${type} id="${id}"${type === 'label' ? ` for="${guid}" class="vjs-label"` : ''}>`,
this.localize(config.label),
`</${type}>`,
`<select aria-labelledby="${selectLabelledbyIds}" id="${guid}">`
].
concat(config.options.map(o => {
const optionId = id + '-' + o[1].replace(/\W+/g, '');
return [
`<option id="${optionId}" value="${o[0]}" `,
`aria-labelledby="${selectLabelledbyIds} ${optionId}">`,
this.localize(o[1]),
'</option>'
].join('');
})).
concat('</select>').join('');
}
/**
* Create foreground color element for the component
*
* @return {string}
* An HTML string.
*
* @private
*/
createElFgColor_() {
const legendId = `captions-text-legend-${this.id_}`;
return [
'<fieldset class="vjs-fg vjs-track-setting">',
`<legend id="${legendId}">`,
this.localize('Text'),
'</legend>',
'<span class="vjs-text-color">',
this.createElSelect_('color', legendId),
'</span>',
'<span class="vjs-text-opacity vjs-opacity">',
this.createElSelect_('textOpacity', legendId),
'</span>',
'</fieldset>'
].join('');
}
/**
* Create background color element for the component
*
* @return {string}
* An HTML string.
*
* @private
*/
createElBgColor_() {
const legendId = `captions-background-${this.id_}`;
return [
'<fieldset class="vjs-bg vjs-track-setting">',
`<legend id="${legendId}">`,
this.localize('Text Background'),
'</legend>',
'<span class="vjs-bg-color">',
this.createElSelect_('backgroundColor', legendId),
'</span>',
'<span class="vjs-bg-opacity vjs-opacity">',
this.createElSelect_('backgroundOpacity', legendId),
'</span>',
'</fieldset>'
].join('');
}
/**
* Create window color element for the component
*
* @return {string}
* An HTML string.
*
* @private
*/
createElWinColor_() {
const legendId = `captions-window-${this.id_}`;
return [
'<fieldset class="vjs-window vjs-track-setting">',
`<legend id="${legendId}">`,
this.localize('Caption Area Background'),
'</legend>',
'<span class="vjs-window-color">',
this.createElSelect_('windowColor', legendId),
'</span>',
'<span class="vjs-window-opacity vjs-opacity">',
this.createElSelect_('windowOpacity', legendId),
'</span>',
'</fieldset>'
].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: [
'<fieldset class="vjs-font-percent vjs-track-setting">',
this.createElSelect_('fontPercent', '', 'legend'),
'</fieldset>',
'<fieldset class="vjs-edge-style vjs-track-setting">',
this.createElSelect_('edgeStyle', '', 'legend'),
'</fieldset>',
'<fieldset class="vjs-font-family vjs-track-setting">',
this.createElSelect_('fontFamily', '', 'legend'),
'</fieldset>'
].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: [
`<button type="button" class="vjs-default-button" title="${defaultsDescription}">`,
this.localize('Reset'),
`<span class="vjs-control-text"> ${defaultsDescription}</span>`,
'</button>',
`<button type="button" class="vjs-done-button">${this.localize('Done')}</button>`
].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();
}
}

View File

@ -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;

View File

@ -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();
});

View File

@ -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);
});

View File

@ -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 <select>s with <options>s', function(assert) {
const player = TestHelpers.makePlayer({
tracks
});
const select = player.textTrackSettings.el_.querySelector('select');
const option = select.querySelector('option');
const selectAriaLabelledby = select.getAttribute('aria-labelledby');
const optionAriaLabelledby = option.getAttribute('aria-labelledby');
assert.ok(
optionAriaLabelledby.includes(selectAriaLabelledby),
"select property 'aria-labelledby' is included in its option's property 'aria-labelledby'"
);
});

View File

@ -0,0 +1,96 @@
/* eslint-env qunit */
import SpatialNavKeyCodes from '../../../src/js/utils/spatial-navigation-key-codes.js';
import TestHelpers from '../test-helpers.js';
QUnit.module('SpatialNavigationKeys', {
beforeEach() {
// 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();
},
afterEach() {
if (this.spatialNav && this.spatialNav.isListening_) {
this.spatialNav.stop();
}
this.player.dispose();
}
});
QUnit.test('should interpret control Keydowns succesfully', function(assert) {
// Create and dispatch a mock keydown event.
const playKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
key: 'play',
code: 'play',
keyCode: 415
});
const isPlayEvent = SpatialNavKeyCodes.isEventKey(playKeydown, 'play');
// Create and dispatch a mock keydown event.
const pauseKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
key: 'pause',
code: 'pause',
keyCode: 19
});
const isPauseEvent = SpatialNavKeyCodes.isEventKey(pauseKeydown, 'pause');
assert.equal(isPlayEvent, true, 'should return true if key pressed was play & play was the expected key');
assert.equal(isPauseEvent, true, 'should return true if key pressed was pause & pause was the expected key');
});
QUnit.test('should return event name type when given a keycode', function(assert) {
// Create and dispatch a mock keydown event.
const ffKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
keyCode: 417
});
const isffEvent = SpatialNavKeyCodes.getEventName(ffKeydown);
// Create and dispatch a mock keydown event.
const rwKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
keyCode: 412
});
const isrwEvent = SpatialNavKeyCodes.getEventName(rwKeydown);
assert.equal(isffEvent, 'ff', 'should return `ff` when passed keycode `417`');
assert.equal(isrwEvent, 'rw', 'should return `rw` when passed keycode `412`');
});
QUnit.test('should return event name if keyCode is not available', function(assert) {
// Create and dispatch a mock keydown event.
const ffKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
keyCode: null,
code: 'ff'
});
const isffEvent = SpatialNavKeyCodes.getEventName(ffKeydown);
// Create and dispatch a mock keydown event.
const rwKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
keyCode: null,
code: 'rw'
});
const isrwEvent = SpatialNavKeyCodes.getEventName(rwKeydown);
assert.equal(isffEvent, 'ff', 'should return `ff` when passed code `ff`');
assert.equal(isrwEvent, 'rw', 'should return `rw` when passed code `rw`');
});
QUnit.test('should return `null` when keycode && code are not passed as parameters', function(assert) {
// Create and dispatch a mock keydown event.
const ffKeydown = new KeyboardEvent('keydown', { // eslint-disable-line no-undef
});
const isffEvent = SpatialNavKeyCodes.getEventName(ffKeydown);
assert.equal(isffEvent, null, 'should return `null` when not passed required parameters');
});