import { EventManager, Utils } from '@playkit-js/playkit-js'; import { ViewabilityConfig } from '../../types'; /** * A service class to observe viewability of elements in the view port. */ class ViewabilityManager { private readonly _observer: IntersectionObserver; private _targetsObserved: Utils.MultiMap; private _config: ViewabilityConfig; private _eventManager: EventManager; private _visibilityTabChangeEventName!: string; private _visibilityTabHiddenAttr!: string; /** * Whether the player browser tab is active or not * @type {boolean} * @private */ private _isTabVisible!: boolean; /** * @param {number} viewabilityConfig - the configuration needed to create the manager * @constructor */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore constructor(viewabilityConfig: ViewabilityConfig = {}) { viewabilityConfig.observedThresholds = viewabilityConfig.observedThresholds || DEFAULT_OBSERVED_THRESHOLDS; viewabilityConfig.playerThreshold = typeof viewabilityConfig.playerThreshold === 'number' ? viewabilityConfig.playerThreshold : DEFAULT_PLAYER_THRESHOLD; this._config = viewabilityConfig; this._eventManager = new EventManager(); this._targetsObserved = new Utils.MultiMap(); const options = { threshold: viewabilityConfig.observedThresholds.map((val: number) => { return val / 100; }) }; this._observer = new window.IntersectionObserver(this._intersectionChangedHandler.bind(this), options); this._initTabVisibility(); } private _intersectionChangedHandler(entries: Array): void { entries.forEach((entry: IntersectionObserverEntry) => { const targetObserveredBindings: Array<_TargetObserveredBinding> = this._targetsObserved.get(entry.target as HTMLElement); targetObserveredBindings.forEach((targetObservedBinding: _TargetObserveredBinding) => { const visible = entry.intersectionRatio >= targetObservedBinding.threshold; targetObservedBinding.lastIntersectionRatio = entry.intersectionRatio; if (visible !== targetObservedBinding.lastVisible) { targetObservedBinding.lastVisible = visible; targetObservedBinding.listener(visible, ViewabilityType.VIEWPORT); } }); }); } private _handleTabVisibilityChange(): void { this._isTabVisible = !document[this._visibilityTabHiddenAttr]; this._targetsObserved.getAll().forEach((targetObservedBinding: _TargetObserveredBinding) => { if (targetObservedBinding.lastVisible) { targetObservedBinding.listener(this._isTabVisible, ViewabilityType.TAB); } }); } private _initTabVisibility(): void { if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support this._visibilityTabHiddenAttr = 'hidden'; this._visibilityTabChangeEventName = 'visibilitychange'; } if (this._visibilityTabHiddenAttr && this._visibilityTabChangeEventName) { this._eventManager.listen(document, this._visibilityTabChangeEventName, this._handleTabVisibilityChange.bind(this)); this._isTabVisible = !document[this._visibilityTabHiddenAttr]; } } /** * @param {HTMLElement} target - the targeted element to check its visibility * @param {Function} listener - the callback to be invoked when visibility is changed (and when starting to observe). The callback is called with a boolean param representing the visibility state * @param {?number} optionalThreshold - a number between 0 to 100 that represents the minimum visible percentage considered as visible * @returns {void} */ public observe(target: HTMLElement, listener: ListenerType, optionalThreshold?: number): void { if (!this._observer) return; const threshold = typeof optionalThreshold === 'number' ? optionalThreshold : this._config.playerThreshold; const newTargetObservedBinding = new _TargetObserveredBinding(threshold / 100, listener); if (target) { if (!this._targetsObserved.has(target)) { this._observer.observe(target); } else { const lastIntersectionRatio = this._targetsObserved.get(target)[0].lastIntersectionRatio; // if observer has already fired the initial callback due to previous observing then we need to invoke it to the new observer manually if (lastIntersectionRatio !== undefined) { newTargetObservedBinding.lastIntersectionRatio = lastIntersectionRatio; newTargetObservedBinding.listener( this._isTabVisible && lastIntersectionRatio >= newTargetObservedBinding.threshold, ViewabilityType.VIEWPORT ); } } this._targetsObserved.push(target, newTargetObservedBinding); } } /** * Remove the listener from the target * @param {HTMLElement} target - the targeted element to remove the listener * @param {Function} listener - the callback function to be removed * @returns {void} */ public unObserve(target: HTMLElement, listener: _TargetObserveredBinding): void { if (!this._observer) return; this._targetsObserved.remove(target, listener); if (!this._targetsObserved.has(target)) { this._observer.unobserve(target); } } /** * cleans all memory allocations. * @override */ public destroy(): void { if (!this._observer) return; this._eventManager.destroy(); this._observer.disconnect(); this._targetsObserved.clear(); } } const ViewabilityType = { VIEWPORT: 'viewport', TAB: 'tab' } as const; type ListenerType = (visible: boolean, reason: string) => any; class _TargetObserveredBinding { public lastVisible!: boolean; public lastIntersectionRatio!: number; public threshold: number; public listener: ListenerType; constructor(threshold: number, listener: ListenerType) { this.threshold = threshold; this.listener = listener; } } const VISIBILITY_CHANGE = 'visibilitychange'; const DEFAULT_OBSERVED_THRESHOLDS: Array = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; const DEFAULT_PLAYER_THRESHOLD: number = 50; export { ViewabilityManager, VISIBILITY_CHANGE, ViewabilityType, DEFAULT_OBSERVED_THRESHOLDS, DEFAULT_PLAYER_THRESHOLD };