UNPKG

rx-player

Version:
653 lines (603 loc) 18.9 kB
/** * Copyright 2015 CANAL+ Group * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import config from "../config"; import log from "../log"; import type { IEventEmitter } from "../utils/event_emitter"; import globalScope from "../utils/global_scope"; import isNonEmptyString from "../utils/is_non_empty_string"; import isNullOrUndefined from "../utils/is_null_or_undefined"; import noop from "../utils/noop"; import type { IReadOnlySharedReference } from "../utils/reference"; import SharedReference from "../utils/reference"; import type { CancellationSignal } from "../utils/task_canceller"; import type { ICompatDocument, ICompatPictureInPictureWindow, IEventTarget, IMediaElement, IMediaEncryptedEvent, } from "./browser_compatibility_types"; const BROWSER_PREFIXES = ["", "webkit", "moz", "ms"]; /** * Find the first supported event from the list given. * @param {HTMLElement} element * @param {string} eventNameSuffix * @returns {Boolean} */ function isEventSupported(element: HTMLElement, eventNameSuffix: string): boolean { const clone = document.createElement(element.tagName); const eventName = "on" + eventNameSuffix; if (eventName in clone) { return true; } else { clone.setAttribute(eventName, "return;"); return ( typeof (clone as HTMLElement & Partial<Record<string, unknown>>)[eventName] === "function" ); } } /** * Find the first supported event from the list given. * @param {HTMLElement} element * @param {Array.<string>} eventNames * @returns {string|undefined} */ function findSupportedEvent( element: HTMLElement, eventNames: string[], ): string | undefined { return eventNames.filter((name) => isEventSupported(element, name))[0]; } /** * @param {Array.<string>} eventNames * @param {Array.<string>|undefined} prefixes * @returns {Array.<string>} */ function eventPrefixed(eventNames: string[], prefixes?: string[]): string[] { return eventNames.reduce( (parent: string[], name: string) => parent.concat( (prefixes === undefined ? BROWSER_PREFIXES : prefixes).map((p) => p + name), ), [], ); } export interface IEventEmitterLike { addEventListener: ( eventName: string, handler: EventListenerOrEventListenerObject, ) => void; removeEventListener: ( eventName: string, handler: EventListenerOrEventListenerObject, ) => void; } export type IEventTargetLike = HTMLElement | IEventEmitterLike | IEventEmitter<unknown>; /** * Returns a function allowing to add event listeners for particular event(s) * optionally automatically adding browser prefixes if needed. * @param {Array.<string>} eventNames - The event(s) to listen to. If multiple * events are set, the event listener will be triggered when any of them emits. * @param {Array.<string>|undefined} [prefixes] - Optional vendor prefixes with * which the event might also be sent. If not defined, default prefixes might be * tested. * @returns {Function} - Returns function allowing to easily add a callback to * be triggered when that event is emitted on a given event target. */ function createCompatibleEventListener( eventNames: Array<"needkey" | "encrypted">, prefixes?: string[], ): ( element: IEventTargetLike, listener: (event: IMediaEncryptedEvent) => void, cancelSignal: CancellationSignal, ) => void; function createCompatibleEventListener( eventNames: string[], prefixes?: string[], ): ( element: IEventTargetLike, listener: (event?: Event) => void, cancelSignal: CancellationSignal, ) => void; function createCompatibleEventListener( eventNames: string[] | Array<"needkey" | "encrypted">, prefixes?: string[], ): ( element: IEventTargetLike, listener: (event?: Event | MediaEncryptedEvent) => void, cancelSignal: CancellationSignal, ) => void { let mem: string | undefined; const prefixedEvents = eventPrefixed(eventNames, prefixes); return ( element: IEventTargetLike, listener: (event?: Event) => void, cancelSignal: CancellationSignal, ) => { if (cancelSignal.isCancelled()) { return; } // if the element is a HTMLElement we can detect // the supported event, and memoize it in `mem` if (typeof HTMLElement !== "undefined" && element instanceof HTMLElement) { if (typeof mem === "undefined") { mem = findSupportedEvent(element, prefixedEvents); } if (isNonEmptyString(mem)) { element.addEventListener(mem, listener); cancelSignal.register(() => { if (mem !== undefined) { element.removeEventListener(mem, listener); } }); } else { if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) { log.warn( `compat: element ${element.tagName}` + " does not support any of these events: " + prefixedEvents.join(", "), ); } return; } } prefixedEvents.forEach((eventName) => { let hasSetOnFn = false; if (typeof element.addEventListener === "function") { (element as IEventEmitterLike).addEventListener(eventName, listener); } else { hasSetOnFn = true; element[("on" + eventName) as keyof IEventTargetLike] = listener; } cancelSignal.register(() => { if (typeof element.removeEventListener === "function") { (element as IEventEmitterLike).removeEventListener(eventName, listener); } if (hasSetOnFn) { delete element[("on" + eventName) as keyof IEventTargetLike]; } }); }); }; } /** * Returns a reference: * - set to `true` when the document is visible * - set to `false` when the document is hidden * @param {Object} stopListening - `CancellationSignal` allowing to free the * ressources allocated to update this value. * @returns {Object} */ function getDocumentVisibilityRef( stopListening: CancellationSignal, ): IReadOnlySharedReference<boolean> { let prefix: string | undefined; const doc = document as ICompatDocument; if (!isNullOrUndefined(doc.hidden)) { prefix = ""; } else if (!isNullOrUndefined(doc.mozHidden)) { prefix = "moz"; } else if (!isNullOrUndefined(doc.msHidden)) { prefix = "ms"; } else if (!isNullOrUndefined(doc.webkitHidden)) { prefix = "webkit"; } const hidden = isNonEmptyString(prefix) ? ((prefix + "Hidden") as "hidden") : "hidden"; const visibilityChangeEvent = isNonEmptyString(prefix) ? prefix + "visibilitychange" : "visibilitychange"; const isHidden = document[hidden]; const ref = new SharedReference(!isHidden, stopListening); addEventListener( document, visibilityChangeEvent, () => { const isVisible = !document[hidden]; ref.setValueIfChanged(isVisible); }, stopListening, ); return ref; } /** * Get video width from Picture-in-Picture window * @param {HTMLMediaElement} mediaElement * @param {Object} pipWindow * @returns {number} */ export interface IPictureInPictureEvent { isEnabled: boolean; pipWindow: ICompatPictureInPictureWindow | null; } /** * Emit when video enters and leaves Picture-In-Picture mode. * @param {HTMLMediaElement} mediaElement * @param {Object} stopListening * @returns {Object} */ function getPictureOnPictureStateRef( mediaElement: IMediaElement, stopListening: CancellationSignal, ): IReadOnlySharedReference<IPictureInPictureEvent> { if ( mediaElement.webkitSupportsPresentationMode === true && typeof mediaElement.webkitSetPresentationMode === "function" ) { const isWebKitPIPEnabled = mediaElement.webkitPresentationMode === "picture-in-picture"; const ref = new SharedReference<IPictureInPictureEvent>( { isEnabled: isWebKitPIPEnabled, pipWindow: null, }, stopListening, ); addEventListener( mediaElement, "webkitpresentationmodechanged", () => { const isEnabled = mediaElement.webkitPresentationMode === "picture-in-picture"; ref.setValue({ isEnabled, pipWindow: null }); }, stopListening, ); return ref; } const isPIPEnabled = document.pictureInPictureElement === (mediaElement as unknown as HTMLElement); const ref = new SharedReference<IPictureInPictureEvent>( { isEnabled: isPIPEnabled, pipWindow: null }, stopListening, ); addEventListener( mediaElement, "enterpictureinpicture", (evt) => { ref.setValue({ isEnabled: true, pipWindow: ( evt as Event & { pictureInPictureWindow?: ICompatPictureInPictureWindow; } ).pictureInPictureWindow ?? null, }); }, stopListening, ); addEventListener( mediaElement, "leavepictureinpicture", () => { ref.setValue({ isEnabled: false, pipWindow: null }); }, stopListening, ); return ref; } /** * Returns a reference: * - Set to `true` when video is considered as visible (the page is visible * and/or the Picture-In-Picture is activated). * - Set to `false` otherwise. * @param {Object} pipStatus * @param {Object} stopListening - `CancellationSignal` allowing to free the * resources reserved to listen to video visibility change. * @returns {Object} */ function getVideoVisibilityRef( pipStatus: IReadOnlySharedReference<IPictureInPictureEvent>, stopListening: CancellationSignal, ): IReadOnlySharedReference<boolean> { const isDocVisibleRef = getDocumentVisibilityRef(stopListening); let currentTimeout: ReturnType<typeof setTimeout> | undefined; const ref = new SharedReference(true, stopListening); stopListening.register(() => { clearTimeout(currentTimeout); currentTimeout = undefined; }); isDocVisibleRef.onUpdate(checkCurrentVisibility, { clearSignal: stopListening, }); pipStatus.onUpdate(checkCurrentVisibility, { clearSignal: stopListening }); checkCurrentVisibility(); return ref; function checkCurrentVisibility(): void { clearTimeout(currentTimeout); currentTimeout = undefined; if (pipStatus.getValue().isEnabled || isDocVisibleRef.getValue()) { ref.setValueIfChanged(true); } else { const { INACTIVITY_DELAY } = config.getCurrent(); currentTimeout = setTimeout(() => { ref.setValueIfChanged(false); }, INACTIVITY_DELAY); } } } /** * Get video width and height from the screen dimensions. * @param {Object} stopListening * @returns {Object} */ function getScreenResolutionRef( stopListening: CancellationSignal, ): IReadOnlySharedReference<{ width: number | undefined; height: number | undefined; pixelRatio: number; }> { const pixelRatio = isNullOrUndefined(globalScope.devicePixelRatio) || globalScope.devicePixelRatio === 0 ? 1 : globalScope.devicePixelRatio; const ref = new SharedReference<{ width: number | undefined; height: number | undefined; pixelRatio: number; }>( { width: globalScope.screen?.width, height: globalScope.screen?.height, pixelRatio, }, stopListening, ); const interval = setInterval(checkScreenResolution, 20000); stopListening.register(function stopUpdating() { clearInterval(interval); }); return ref; function checkScreenResolution() { const oldVal = ref.getValue(); if ( oldVal.width !== screen.width || oldVal.height !== screen.height || oldVal.pixelRatio !== pixelRatio ) { ref.setValue({ width: screen.width, height: screen.height, pixelRatio }); } } } /** * Get video width and height from HTML media element, or video estimated * dimensions when Picture-in-Picture is activated. * @param {HTMLMediaElement} mediaElement * @param {Object} pipStatusRef * @param {Object} stopListening * @returns {Object} */ function getElementResolutionRef( mediaElement: IMediaElement, pipStatusRef: IReadOnlySharedReference<IPictureInPictureEvent>, stopListening: CancellationSignal, ): IReadOnlySharedReference<{ width: number | undefined; height: number | undefined; pixelRatio: number; }> { const pixelRatio = isNullOrUndefined(globalScope.devicePixelRatio) || globalScope.devicePixelRatio === 0 ? 1 : globalScope.devicePixelRatio; const ref = new SharedReference<{ width: number | undefined; height: number | undefined; pixelRatio: number; }>( { width: mediaElement.clientWidth, height: mediaElement.clientHeight, pixelRatio, }, stopListening, ); let clearPreviousEventListener = noop; pipStatusRef.onUpdate(checkElementResolution, { clearSignal: stopListening }); addEventListener(globalScope, "resize", checkElementResolution, stopListening); addEventListener( mediaElement, "enterpictureinpicture", checkElementResolution, stopListening, ); addEventListener( mediaElement, "leavepictureinpicture", checkElementResolution, stopListening, ); const interval = setInterval(checkElementResolution, 20000); checkElementResolution(); stopListening.register(function stopUpdating() { clearPreviousEventListener(); clearInterval(interval); }); return ref; function checkElementResolution() { clearPreviousEventListener(); const pipStatus = pipStatusRef.getValue(); const { pipWindow } = pipStatus; if (!pipStatus.isEnabled) { const oldVal = ref.getValue(); if ( oldVal.width !== mediaElement.clientWidth || oldVal.height !== mediaElement.clientHeight || oldVal.pixelRatio !== pixelRatio ) { ref.setValue({ width: mediaElement.clientWidth, height: mediaElement.clientHeight, pixelRatio, }); } } else if (!isNullOrUndefined(pipWindow)) { const onPipResize = () => { updateToPipWindowResolution(); }; pipWindow.addEventListener("resize", onPipResize); clearPreviousEventListener = () => { pipWindow.removeEventListener("resize", onPipResize); clearPreviousEventListener = noop; }; updateToPipWindowResolution(); } else { const oldVal = ref.getValue(); if ( oldVal.width !== undefined || oldVal.height !== undefined || oldVal.pixelRatio !== pixelRatio ) { ref.setValue({ width: undefined, height: undefined, pixelRatio }); } } function updateToPipWindowResolution() { const oldVal = ref.getValue(); if ( oldVal.width !== pipWindow?.width || oldVal.height !== pipWindow?.height || oldVal.pixelRatio !== pixelRatio ) { ref.setValue({ width: pipWindow?.width, height: pipWindow?.height, pixelRatio, }); } } } } /** * @param {HTMLMediaElement} mediaElement */ const onLoadedMetadata = createCompatibleEventListener(["loadedmetadata"]); /** * @param {HTMLMediaElement} mediaElement */ const onTimeUpdate = createCompatibleEventListener(["timeupdate"]); /** * @param {TextTrackList} mediaElement */ const onTextTrackAdded = createCompatibleEventListener(["addtrack"]); /** * @param {TextTrackList} textTrackList */ const onTextTrackRemoved = createCompatibleEventListener(["removetrack"]); /** * @param {MediaSource} mediaSource * @param {Function} listener * @param {Object} cancelSignal */ const onSourceOpen = createCompatibleEventListener(["sourceopen", "webkitsourceopen"]); /** * @param {MediaSource} mediaSource * @param {Function} listener * @param {Object} cancelSignal */ const onSourceClose = createCompatibleEventListener(["sourceclose", "webkitsourceclose"]); /** * @param {MediaSource} mediaSource * @param {Function} listener * @param {Object} cancelSignal */ const onSourceEnded = createCompatibleEventListener(["sourceended", "webkitsourceended"]); /** * @param {MediaSource} mediaSource * @param {Function} listener * @param {Object} cancelSignal */ const onSourceBufferUpdate = createCompatibleEventListener(["update"]); /** * @param {SourceBufferList} sourceBuffers * @param {Function} listener * @param {Object} cancelSignal */ const onRemoveSourceBuffers = createCompatibleEventListener(["removesourcebuffer"]); /** * @param {MediaKeySession} mediaKeySession */ const onKeyMessage = createCompatibleEventListener(["keymessage", "message"]); /** * @param {MediaKeySession} mediaKeySession */ const onKeyAdded = createCompatibleEventListener(["keyadded", "ready"]); /** * @param {MediaKeySession} mediaKeySession */ const onKeyError = createCompatibleEventListener(["keyerror", "error"]); /** * @param {MediaKeySession} mediaKeySession */ const onKeyStatusesChange = createCompatibleEventListener(["keystatuseschange"]); /** * @param {HTMLMediaElement} mediaElement */ const onSeeking = createCompatibleEventListener(["seeking"]); /** * @param {HTMLMediaElement} mediaElement */ const onSeeked = createCompatibleEventListener(["seeked"]); /** * @param {HTMLMediaElement} mediaElement */ const onEnded = createCompatibleEventListener(["ended"]); /** * Utilitary function allowing to add an event listener and remove it * automatically once the given `CancellationSignal` emits. * @param {EventTarget} elt - The element on which should be attached the event * listener. * @param {string} evt - The event you wish to listen to * @param {Function} listener - The listener function * @param {Object} stopListening - Removes the event listener once this signal * emits */ function addEventListener( elt: IEventEmitterLike | IEventTarget<Record<string, Event>>, evt: string, listener: (x?: unknown) => void, stopListening: CancellationSignal, ): void { elt.addEventListener(evt, listener); stopListening.register(() => { elt.removeEventListener(evt, listener); }); } export { addEventListener, createCompatibleEventListener, getPictureOnPictureStateRef, getVideoVisibilityRef, getElementResolutionRef, getScreenResolutionRef, onEnded, onKeyAdded, onKeyError, onKeyMessage, onKeyStatusesChange, onLoadedMetadata, onRemoveSourceBuffers, onSeeked, onSeeking, onSourceClose, onSourceEnded, onSourceOpen, onTimeUpdate, onSourceBufferUpdate, onTextTrackAdded, onTextTrackRemoved, };