summaryrefslogtreecommitdiff
path: root/javascript/videojs/src/js/spatial-navigation.js
diff options
context:
space:
mode:
Diffstat (limited to 'javascript/videojs/src/js/spatial-navigation.js')
-rw-r--r--javascript/videojs/src/js/spatial-navigation.js644
1 files changed, 644 insertions, 0 deletions
diff --git a/javascript/videojs/src/js/spatial-navigation.js b/javascript/videojs/src/js/spatial-navigation.js
new file mode 100644
index 0000000..08ca9bc
--- /dev/null
+++ b/javascript/videojs/src/js/spatial-navigation.js
@@ -0,0 +1,644 @@
+/**
+ * @file spatial-navigation.js
+ */
+import EventTarget from './event-target';
+import SpatialNavKeyCodes from './utils/spatial-navigation-key-codes';
+
+/** @import Component from './component' */
+/** @import Player from './player' */
+
+// 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 {Player} 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;
+ if (this.player_.errorDisplay) {
+ this.player_.errorDisplay.on('aftermodalfill', () => {
+ this.updateFocusableComponents();
+
+ if (this.focusableComponents.length) {
+ // The modal has focusable components:
+
+ if (this.focusableComponents.length > 1) {
+ // The modal has close button + some additional buttons.
+ // Focusing first additional button:
+
+ this.focusableComponents[1].focus();
+ } else {
+ // The modal has only close button,
+ // Focusing it:
+
+ this.focusableComponents[0].focus();
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * 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 (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(actualEvent.key)) {
+ // Handle directional navigation
+ if (this.isPaused_) {
+ return;
+ }
+ actualEvent.preventDefault();
+
+ // "ArrowLeft" => "left" etc
+ const direction = actualEvent.key.substring(5).toLowerCase();
+
+ 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 && typeof event.target.closeable === 'function' && 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 && 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);
+ }
+ }
+
+ // TODO - Refactor the following logic after refactor of videojs-errors elements to be components is done.
+ if (value.name_ === 'ErrorDisplay' && value.opened_) {
+ const buttonContainer = value.el_.querySelector('.vjs-errors-ok-button-container');
+
+ if (buttonContainer) {
+ const modalButtons = buttonContainer.querySelectorAll('button');
+
+ modalButtons.forEach((element, index) => {
+ // Add elements as objects to be handled by the spatial navigation
+ focusableComponents.push({
+ name: () => {
+ return 'ModalButton' + (index + 1);
+ },
+ el: () => element,
+ getPositions: () => {
+ const rect = element.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
+ };
+ },
+ // Asume that the following are always focusable
+ getIsAvailableToBeFocused: () => true,
+ getIsFocusable: (el) => true,
+ focus: () => element.focus()
+ });
+ });
+ }
+ }
+ });
+
+ 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;
+ }
+
+ if (component.el()) {
+ return searchForSuitableChild(component.el());
+ }
+ return null;
+
+ }
+
+ /**
+ * 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 user 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 (typeof component !== 'object') {
+ return;
+ }
+
+ 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;