/**
* @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(node) && 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.
*
* @private
*/
searchForTrackSelect_() {
const spatialNavigation = this;
for (const component of (spatialNavigation.updateFocusableComponents())) {
if (component.constructor.name === 'TextTrackSelect') {
spatialNavigation.focus(component);
break;
}
}
}
}
export default SpatialNavigation;