import DomSmith from '../../lib/dom/DomSmith.js';
/**
* The UI component serves as the parent container for all UI-related elements within the video player.
* It manages the display of the interface by providing auto-hide and show functionality based on user interactions and timeouts.
* Additionally, it implements basic responsive design features, allowing CSS and other components to adapt the layout based on viewport size changes.
* @exports module:src/ui/UI
* @requires lib/dom/DomSmith
* @author Frank Kudermann - alphanull
* @version 1.1.0
* @license MIT
*/
export default class UI {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {boolean} [alwaysVisible=false] If `true`, the UI never auto-hides.
* @property {number} [autoHide=5] Time (in seconds) after which the UI auto-hides (0 disables).
* @property {boolean} [clickToPlay=true] If `true`, clicking on the video element toggles play/pause.
* @property {boolean} [showScaleSlider=true] If `true`, the UI scale slider is shown in the settings popup.
* @property {string} [iconStyle='default'] The style of the icons: 'default' or 'filled'.
* @property {number} [uiScale=1] Initial scale factor for the UI.
* @property {string} [wakeLock='fullscreen'] Wake lock behavior: `'off'` disables it, `'always'` enables it always while playing, `'fullscreen'` enables it only when in fullscreen mode.
*/
#config = {
alwaysVisible: false,
autoHide: 5,
clickToPlay: true,
showScaleSlider: true,
iconStyle: 'default',
uiScale: 1,
wakeLock: 'fullscreen'
};
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Holds tokens of subscriptions to player events, for later unsubscribe.
* @type {number[]}
*/
#subscriptions;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* The root element in which the UI is placed.
* @type {HTMLElement}
*/
#rootEle;
/**
* DomSmith instance for the settings menu slider.
* @type {module:lib/dom/DomSmith|undefined}
*/
#menu;
/**
* Reference to the settings popup component.
* @type {module:lib/dom/DomSmith|undefined}
*/
#settingsPopup;
/**
* Reflects the state of the UI.
* @type {Object}
* @property {string} state The UI's current state: 'visible' or 'hidden'.
* @property {boolean|string} hasFocus Reflects whether the video player currently has focus and which type of element has focus.
* @property {number} playerWidth Current known width of the player's container.
* @property {number} playerHeight Current known height of the player's container.
* @property {string} lastInput Type of last user interaction: `mouse`or `touch`.
*/
#state = {
visibility: '',
hasFocus: true,
playerWidth: 0,
playerHeight: 0,
scale: 1,
lastInput: ''
};
/**
* Resize Observer, used for resize functionality (uses standard resize event if API not available).
* @type {ResizeObserver}
*/
#resizeObserver;
/**
* Holds the active wake lock instance, if any.
* @type {WakeLockSentinel|null}
*/
#wakeLock = null;
/**
* Flag indicating that the next tap is only meant to reveal the UI.
* @type {boolean}
*/
#suppressNextTouch = false;
/**
* The ID for a pending hide timer.
* @type {number}
*/
#hideTimeOutId;
/**
* Flag indicating if the UI is initialized.
* @type {boolean}
*/
#initialized = false;
/**
* Creates an instance of the UI component.
* @param {module:src/core/Player} player Reference to the VisionPlayer instance.
* @param {module:src/core/Player} parent Reference to the parent instance.
* @param {Object} [options] Additional options.
* @param {symbol} [options.apiKey] Token for extended access to the player API.
*/
constructor(player, parent, { apiKey }) {
this.#config = player.initConfig('ui', this.#config);
if (!this.#config) return [false];
this.#apiKey = apiKey;
this.#player = player;
this.#player.setState('ui.visible', { get: () => this.#state.visibility === 'visible' }, this.#apiKey);
this.#player.setState('ui.hasFocus', { get: () => this.#state.hasFocus }, this.#apiKey);
this.#player.setState('ui.lastInput', { get: () => this.#state.lastInput }, this.#apiKey);
this.#player.setState('ui.playerWidth', { get: () => this.#state.playerWidth }, this.#apiKey);
this.#player.setState('ui.playerHeight', { get: () => this.#state.playerHeight }, this.#apiKey);
this.#player.setState('ui.scale', { get: () => this.#state.scale }, this.#apiKey);
this.#player.setApi('ui.hide', this.#hide, this.#apiKey);
this.#player.setApi('ui.show', this.#show, this.#apiKey);
this.#player.setApi('ui.disableAutoHide', this.#disableAutoHide, this.#apiKey);
this.#player.setApi('ui.enableAutoHide', this.#enableAutoHide, this.#apiKey);
this.#player.setApi('ui.resize', this.#resize, this.#apiKey);
this.#rootEle = player.dom.getElement(apiKey);
this.#rootEle.addEventListener('keydown', this.#onInput);
this.#rootEle.addEventListener('pointerdown', this.#onInput);
// focus helper to determine if player is in focus
// (for example, could be used it with keyboard navigation)
this.#rootEle.addEventListener('focus', this.#onFocus, true);
document.addEventListener('focus', this.#onFocus, true);
if (this.#config.iconStyle === 'filled') {
this.#rootEle.classList.add('icon-filled');
}
this.#subscriptions = [
this.#player.subscribe('dom/ready', this.#onDomReady),
this.#player.subscribe('data/ready', this.#onDataReady),
this.#player.subscribe('popup/show', this.#disableAutoHide),
this.#player.subscribe('popup/hidden', this.#enableAutoHide)
];
if (this.#config.wakeLock !== 'off' && 'wakeLock' in navigator) {
document.addEventListener('visibilitychange', this.#onVisibilityChange);
this.#subscriptions.push(
this.#player.subscribe('fullscreen/enter', this.#requestWakeLock),
this.#player.subscribe('fullscreen/leave', this.#releaseWakeLock),
this.#player.subscribe('media/play', this.#requestWakeLock),
this.#player.subscribe('media/pause', this.#releaseWakeLock),
this.#player.subscribe('media/ended', this.#releaseWakeLock),
this.#player.subscribe('media/error', this.#releaseWakeLock)
);
}
// use ResizeObserver, if supported
if (typeof ResizeObserver === 'undefined') window.addEventListener('resize', this.#resize);
else {
this.#resizeObserver = new ResizeObserver(this.#resize);
this.#resizeObserver.observe(this.#rootEle);
}
}
/**
* Called when the player has fully initialized to set up UI.
* @listens module:src/core/Dom#dom/ready
*/
#onDomReady = () => {
// check if we have the settings menu available for additional UI
this.#settingsPopup = this.#player.getComponent('ui.controller.popupSettings', this.#apiKey);
if (this.#config.showScaleSlider && this.#settingsPopup) {
this.#menu = new DomSmith({
_ref: 'menu',
className: 'vip-menu',
_nodes: [{
_tag: 'label',
_nodes: [
{
_tag: 'span',
className: 'form-label-text',
_nodes: [
this.#player.locale.t('misc.uiScale'),
{
_ref: 'scaleLabel',
_text: ` (x${this.#config.uiScale})`
}
]
}, {
_tag: 'input',
_ref: 'slider',
type: 'range',
min: 0,
max: 2,
step: 0.1,
value: this.#config.uiScale >= 1 ? this.#config.uiScale : 0.5 + this.#config.uiScale / 2,
ariaLabel: this.#player.locale.t('misc.uiScale'),
className: 'has-center-line',
change: this.#setUiScale
}
]
}]
}, { ele: this.#settingsPopup.getElement('bottom'), insertMode: 'top' });
}
if (this.#config.uiScale !== 1) {
this.#setUiScale({ target: { value: this.#config.uiScale } });
}
};
/**
* Called when the data is ready to set up UI.
* @listens module:src/core/Data#data/ready
*/
#onDataReady = () => {
if (!this.#initialized) {
this.#enable();
if (!this.#resizeObserver) this.#resize();
this.#initialized = true;
if (this.#config.wakeLock !== 'off' && 'wakeLock' in navigator && !this.#player.getState('media.paused')) this.#requestWakeLock();
}
};
/**
* Sets the UI scale.
* @param {InputEvent} event The input event which called this handler.
*/
#setUiScale = ({ target }) => {
let value = Number(target.value);
value = value >= 1 ? value : 0.5 + value / 2;
this.#menu.scaleLabel.textContent = ` (x${value})`;
this.#menu.slider.setAttribute('aria-valuetext', `x${value}`);
this.#rootEle.style.setProperty('--vip-ui-scale', value);
this.#state.scale = value;
this.#player.publish('ui/resize', { width: this.#state.playerWidth, height: this.#state.playerHeight }, this.#apiKey);
};
/**
* Handles toggle between play and pause when the user interacts with the UI.
* On touch devices, the first tap reveals the UI without toggling playback.
* The second tap (while UI is visible) will then toggle play/pause as expected.
* @param {PointerEvent} event The pointerdown event that triggered the handler.
*/
#onTogglePlay = ({ pointerType, target }) => {
if (pointerType === 'touch' && this.#suppressNextTouch) {
this.#suppressNextTouch = false;
return;
}
this.#suppressNextTouch = false;
if (target !== this.#rootEle && !target.classList.contains('click-through')) return;
if (this.#player.getState('media.paused')) this.#player.media.play();
else this.#player.media.pause();
};
/**
* Fires "ui/show" event, and removes the "hidden" class from the UI wrapper element.
* @param {Event} event The event which called this handler.
* @fires module:src/ui/UI#ui/show
*/
#show = ({ type, pointerType, relatedTarget } = {}) => {
// dont accidentally show if mouseenter comes from within (e.g. when coming from closing the popup)
if (this.#state.visibility === 'visible' || type === 'pointerenter' && (!relatedTarget || !relatedTarget.isConnected || this.#rootEle.contains(relatedTarget))) return;
if (pointerType === 'touch' && this.#state.visibility !== 'visible') {
this.#suppressNextTouch = true;
}
this.#state.visibility = 'visible';
this.#rootEle.classList.remove('ui-hidden');
this.#player.publish('ui/show', this.#apiKey);
};
/**
* Fires "ui/hide" event, and adds a "hidden" class to the UI wrapper element.
* @param {Event} event The event which called this handler.
* @fires module:src/ui/UI#ui/hide
*/
#hide = ({ relatedTarget } = {}) => {
if (this.#state.visibility === 'hidden' || typeof relatedTarget !== 'undefined' && (relatedTarget === null || relatedTarget.tagName === 'SELECT')) return;
this.#state.visibility = 'hidden';
this.#rootEle.classList.add('ui-hidden');
this.#player.publish('ui/hide', this.#apiKey);
};
/**
* Enables "autohide" funtionality. Might be called via the player API, for example to enable autohiding again when a popup is closed.
* @listens module:src/util/PopupWrapper#popup/hide
*/
#enableAutoHide = () => {
if (this.#config.alwaysVisible || this.#config.autoHide <= 0) return;
this.#rootEle.addEventListener('pointerdown', this.#onRefreshTimer);
this.#rootEle.addEventListener('pointermove', this.#onRefreshTimer);
clearTimeout(this.#hideTimeOutId);
this.#hideTimeOutId = setTimeout(this.#hide, this.#config.autoHide * 1000);
};
/**
* Disables "autohide" funtionality. Might be called via the player API, for example to prevent autohiding when a popup is open.
* @listens module:src/util/PopupWrapper#popup/show
*/
#disableAutoHide = () => {
if (this.#config.alwaysVisible || this.#config.autoHide <= 0) return;
this.#rootEle.removeEventListener('pointerdown', this.#onRefreshTimer);
this.#rootEle.removeEventListener('pointermove', this.#onRefreshTimer);
clearTimeout(this.#hideTimeOutId);
};
/**
* Enables (auto)hiding and clickToPlay.
* @fires module:src/ui/UI#ui/enabled
*/
#enable() {
this.#enableClickPlay();
if (!this.#config.alwaysVisible) {
if (this.#config.autoHide > 0) this.#enableAutoHide();
this.#rootEle.addEventListener('pointerdown', this.#show);
this.#rootEle.addEventListener('pointerenter', this.#show);
this.#rootEle.addEventListener('pointerleave', this.#hide);
} else if (!this.#player.getConfig('media.autoPlay') || this.#config.alwaysVisible) {
this.#show();
}
this.#rootEle.classList.remove('is-disabled');
this.#player.publish('ui/enabled', this.#apiKey);
}
/**
* Disables (auto)hiding and clickToPlay.
* @fires module:src/ui/UI#ui/disabled
*/
#disable() {
clearTimeout(this.#hideTimeOutId);
this.#releaseWakeLock();
this.#hide();
this.#disableClickPlay();
this.#disableAutoHide();
this.#rootEle.removeEventListener('pointerdown', this.#show);
this.#rootEle.removeEventListener('pointerenter', this.#show);
this.#rootEle.removeEventListener('pointerleave', this.#hide);
this.#rootEle.classList.add('is-disabled');
this.#player.unsubscribe('media/canplay', this.#enableClickPlay);
this.#player.publish('ui/disabled', this.#apiKey);
}
/**
* Enables the "click to play" functionality. This method listens to canplay events inorder to restore a usable state again
* when the player recovered from a media error (for example by loading another file).
* @listens module:src/core/Media#media/canplay
*/
#enableClickPlay = () => {
if (!this.#config.clickToPlay) return;
this.#rootEle.classList.add('is-clickable');
this.#rootEle.addEventListener('pointerup', this.#onTogglePlay, { capture: true });
this.#player.unsubscribe('media/canplay', this.#enableClickPlay);
this.#player.subscribe('media/error', this.#disableClickPlay);
};
/**
* Disables "click to play" funtionality.
* @listens module:src/core/Media#media/error
*/
#disableClickPlay = () => {
if (!this.#config.clickToPlay) return;
this.#rootEle.classList.remove('is-clickable');
this.#rootEle.removeEventListener('pointerup', this.#onTogglePlay, { capture: true });
this.#player.unsubscribe('media/error', this.#disableClickPlay);
this.#player.subscribe('media/canplay', this.#enableClickPlay);
};
/**
* Resets the UI auto-hide timer on pointer interactions.
* @param {PointerEvent} event The pointer event that triggered the handler.
*/
#onRefreshTimer = event => {
clearTimeout(this.#hideTimeOutId);
this.#hideTimeOutId = setTimeout(this.#hide, this.#config.autoHide * 1000);
if (this.#state.visibility !== 'visible' && event.pointerType !== 'touch') this.#show();
};
/**
* Helper function called when the videoplayer has focus. Used mainly for determining if the playerr should process keyboard events.
*/
#onFocus = () => {
const isShadow = this.#player.getConfig('dom.shadow'),
target = isShadow ? this.#rootEle.parentNode.activeElement : document.activeElement;
this.#state.hasFocus = false;
if (this.#rootEle.contains(target)) {
this.#state.hasFocus = true;
if (target.tagName === 'SELECT') this.#state.hasFocus = 'select';
else if (target.tagName === 'BUTTON') this.#state.hasFocus = 'button';
else if (target.tagName === 'INPUT') this.#state.hasFocus = target.type === 'range' ? 'slider' : 'input';
}
};
/**
* Reacts to pointerdown and keyboard events to determine
* the last input method, which is exposed on the players state.
* @param {PointerEvent|KeyboardEvent} event The pointer or keyboard event.
*/
#onInput = event => {
if (event.type === 'pointerdown') this.#state.lastInput = event.pointerType;
else if (['Tab', 'ArrowLeft', 'ArrowRight'].includes(event.key)) this.#state.lastInput = 'keyboard';
};
/**
* Requests a screen wake lock while media is playing and page is visible.
* @listens module:src/core/Media#media/play
*/
#requestWakeLock = async() => {
if (this.#wakeLock
|| document.visibilityState !== 'visible'
|| this.#player.getState('media.paused')
|| this.#config.wakeLock === 'fullscreen' && !this.#player.getState('ui.fullscreen')) return;
try {
this.#wakeLock = await navigator.wakeLock.request('screen');
this.#wakeLock.addEventListener('release', this.#onWakeLockRelease);
} catch {
this.#wakeLock = null;
}
};
/**
* Releases the active wake lock if present.
* @listens module:src/core/Media#media/pause
* @listens module:src/core/Media#media/ended
* @listens module:src/core/Media#media/error
*/
#releaseWakeLock = () => {
if (!this.#wakeLock) return;
this.#wakeLock.removeEventListener('release', this.#onWakeLockRelease);
this.#wakeLock.release().catch(() => {});
this.#wakeLock = null;
};
/**
* Reacquires wake lock when it is lost unexpectedly while playing.
*/
#onWakeLockRelease = () => {
this.#wakeLock = null;
if (document.visibilityState === 'visible'
&& !this.#player.getState('media.paused')
&& !(this.#config.wakeLock === 'fullscreen' && !this.#player.getState('ui.fullscreen'))) {
this.#requestWakeLock();
}
};
/**
* Handles tab visibility changes to toggle wake lock.
*/
#onVisibilityChange = () => {
if (document.visibilityState === 'visible') this.#requestWakeLock();
else this.#releaseWakeLock();
};
/**
* The resize handler provides basic "responsive design" functionality.
* As the player might be a widget in a surrounding layout, the typical CSS media queries
* don't work here. Instead, the resize method checks if the width / height of the player
* reaches certain breakpoints, and adds appropriate classes accordingly, which in turn might be used
* to control layout / appearance in CSS.
* @param {ResizeObserverEntry[]} [entries] The entries if using ResizeObserver, otherwise `undefined`.
* @fires module:src/ui/UI#ui/resize
*/
#resize = entries => {
const rect = entries?.[0]?.contentRect;
this.#state.playerWidth = rect ? rect.width : this.#rootEle.clientWidth;
this.#state.playerHeight = rect ? rect.height : this.#rootEle.clientHeight;
const width = this.#state.playerWidth,
height = this.#state.playerHeight;
const scaleMinWidth = 480,
scaleMaxWidth = 1400;
const isShadow = this.#player.getConfig('dom.shadow'),
rootEle = isShadow ? this.#rootEle.parentNode.host : this.#rootEle;
rootEle.style.setProperty('--vip-width-scale', Math.max(0, Math.min(1, (width - scaleMinWidth) / (scaleMaxWidth - scaleMinWidth))));
this.#rootEle.classList.toggle('width-low', width < 480);
this.#rootEle.classList.toggle('width-med', width < 600 && width >= 480);
if (!height) return;
this.#player.publish('ui/resize', { width, height }, this.#apiKey);
};
/**
* Used by child components to retrieve a container element they can attach.
* @returns {HTMLElement} The element designated by the component as attachable container.
*/
getElement() {
return this.#rootEle;
}
/**
* This method removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
this.#disable();
if (this.#resizeObserver) {
this.#resizeObserver.disconnect();
this.#resizeObserver = null;
} else window.removeEventListener('resize', this.#resize);
document.removeEventListener('focus', this.#onFocus, true);
this.#rootEle.removeEventListener('focus', this.#onFocus, true);
this.#player.dom.getElement(this.#apiKey).removeEventListener('keydown', this.#onInput);
this.#player.dom.getElement(this.#apiKey).removeEventListener('pointerdown', this.#onInput);
this.#player.unsubscribe(this.#subscriptions);
this.#player.removeApi(['ui.hide', 'ui.show', 'ui.disableAutoHide', 'ui.enableAutoHide', 'ui.resize'], this.#apiKey);
this.#player.removeState(['ui'], this.#apiKey);
document.removeEventListener('visibilitychange', this.#onVisibilityChange);
this.#releaseWakeLock();
this.#player = this.#rootEle = this.#apiKey = null;
}
}
/**
* This event is fired when the UI is shown.
* @event module:src/ui/UI#ui/show
*/
/**
* This event is fired when the UI is hidden.
* @event module:src/ui/UI#ui/hide
*/
/**
* This event is fired when the UI is enabled.
* @event module:src/ui/UI#ui/enabled
*/
/**
* This event is fired when the UI is disabled.
* @event module:src/ui/UI#ui/disabled
*/
/**
* Fired when the player viewport resizes, and also once when viewport is inserted in the dom.
* @event module:src/ui/UI#ui/resize
* @param {Object} size New size of the player viewport.
* @param {number} size.width New width of the player viewport.
* @param {number} size.height New height of the player viewport.
*/