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:
parent
582c35f96b
commit
21b4a5225b
@ -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%;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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();
|
||||
|
553
src/js/spatial-navigation.js
Normal file
553
src/js/spatial-navigation.js
Normal 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;
|
129
src/js/tracks/text-track-fieldset.js
Normal file
129
src/js/tracks/text-track-fieldset.js
Normal 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;
|
79
src/js/tracks/text-track-select.js
Normal file
79
src/js/tracks/text-track-select.js
Normal 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;
|
104
src/js/tracks/text-track-settings-colors.js
Normal file
104
src/js/tracks/text-track-settings-colors.js
Normal 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;
|
60
src/js/tracks/text-track-settings-controls.js
Normal file
60
src/js/tracks/text-track-settings-controls.js
Normal 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;
|
101
src/js/tracks/text-track-settings-font.js
Normal file
101
src/js/tracks/text-track-settings-font.js
Normal 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;
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
47
src/js/utils/spatial-navigation-key-codes.js
Normal file
47
src/js/utils/spatial-navigation-key-codes.js
Normal 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;
|
@ -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();
|
||||
});
|
||||
|
493
test/unit/spatial-navigation.test.js
Normal file
493
test/unit/spatial-navigation.test.js
Normal 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);
|
||||
});
|
25
test/unit/tracks/text-track-select.test.js
Normal file
25
test/unit/tracks/text-track-select.test.js
Normal 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'"
|
||||
);
|
||||
});
|
96
test/unit/utils/spatial-navigation-key-codes.test.js
Normal file
96
test/unit/utils/spatial-navigation-key-codes.test.js
Normal 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');
|
||||
});
|
Loading…
Reference in New Issue
Block a user