mirror of
https://github.com/videojs/video.js.git
synced 2026-06-15 21:54:53 +02:00
556 lines
19 KiB
JavaScript
556 lines
19 KiB
JavaScript
/**
|
|
* @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;
|