UNPKG

rx-player

Version:
1,498 lines (1,379 loc) 122 kB
/** * Copyright 2015 CANAL+ Group * * Licensed under the Apache License, Version 2.0 (the "License");publicapi * 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. */ /** * This file defines the public API for the RxPlayer. * It also starts the different sub-parts of the player on various API calls. */ import type { IMediaElement } from "../../compat/browser_compatibility_types"; import canRelyOnVideoVisibilityAndSize from "../../compat/can_rely_on_video_visibility_and_size"; import type { IPictureInPictureEvent } from "../../compat/event_listeners"; import { getPictureOnPictureStateRef, getVideoVisibilityRef, getElementResolutionRef, getScreenResolutionRef, } from "../../compat/event_listeners"; import getStartDate from "../../compat/get_start_date"; import hasMseInWorker from "../../compat/has_mse_in_worker"; import hasWorkerApi from "../../compat/has_worker_api"; import config from "../../config"; import type { ISegmentSinkMetrics } from "../../core/segment_sinks/segment_sinks_store"; import type { IAdaptationChoice, IInbandEvent, IABRThrottlers, IBufferType, } from "../../core/types"; import type { IDefaultConfig } from "../../default_config"; import type { IErrorCode, IErrorType } from "../../errors"; import { ErrorCodes, ErrorTypes, formatError, MediaError } from "../../errors"; import WorkerInitializationError from "../../errors/worker_initialization_error"; import type { IFeature } from "../../features"; import features, { addFeatures } from "../../features"; import log from "../../log"; import type { IDecipherabilityStatusChangedElement, IAdaptationMetadata, IManifestMetadata, IPeriodMetadata, IRepresentationMetadata, IPeriodsUpdateResult, IManifest, } from "../../manifest"; import { getLivePosition, getMaximumSafePosition, getMinimumSafePosition, ManifestMetadataFormat, createRepresentationFilterFromFnString, getPeriodForTime, toVideoRepresentation, toAudioRepresentation, } from "../../manifest"; import type { IWorkerMessage } from "../../multithread_types"; import { MainThreadMessageType, WorkerMessageType } from "../../multithread_types"; import type { IPlaybackObservation } from "../../playback_observer"; import MediaElementPlaybackObserver from "../../playback_observer/media_element_playback_observer"; import type { IAudioRepresentation, IAudioRepresentationsSwitchingMode, IAudioTrack, IAudioTrackSetting, IAudioTrackSwitchingMode, IAvailableAudioTrack, IAvailableTextTrack, IAvailableVideoTrack, IBrokenRepresentationsLockContext, IConstructorOptions, IKeySystemConfigurationOutput, IKeySystemOption, ILoadVideoOptions, ILockedAudioRepresentationsSettings, ILockedVideoRepresentationsSettings, ITrackUpdateEventPayload, IRepresentationListUpdateContext, IPeriod, IPeriodChangeEvent, IPlayerError, IPlayerState, IPositionUpdate, IStreamEvent, ITextTrack, IVideoRepresentation, ITextTrackSetting, IVideoRepresentationsSwitchingMode, IVideoTrack, IVideoTrackSetting, IVideoTrackSwitchingMode, ITrackType, IModeInformation, IWorkerSettings, IThumbnailTrackInfo, IThumbnailRenderingOptions, INoPlayableTrackEventPayload, } from "../../public_types"; import type { IThumbnailResponse } from "../../transports"; import arrayFind from "../../utils/array_find"; import arrayIncludes from "../../utils/array_includes"; import assert, { assertUnreachable } from "../../utils/assert"; import type { IEventPayload, IListener } from "../../utils/event_emitter"; import EventEmitter from "../../utils/event_emitter"; import globalScope from "../../utils/global_scope"; import idGenerator from "../../utils/id_generator"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import type Logger from "../../utils/logger"; import getMonotonicTimeStamp from "../../utils/monotonic_timestamp"; import objectAssign from "../../utils/object_assign"; import { getLeftSizeOfBufferedTimeRange } from "../../utils/ranges"; import type { IReadOnlySharedReference } from "../../utils/reference"; import SharedReference, { createMappedReference } from "../../utils/reference"; import type { CancellationSignal } from "../../utils/task_canceller"; import TaskCanceller from "../../utils/task_canceller"; import { clearOnStop, disposeDecryptionResources, getKeySystemConfiguration, } from "../decrypt"; import type { ContentInitializer } from "../init"; import renderThumbnail from "../render_thumbnail"; import type { IMediaElementTracksStore, ITSPeriodObject } from "../tracks_store"; import TracksStore from "../tracks_store"; import type { IParsedLoadVideoOptions, IParsedStartAtOption } from "./option_utils"; import { checkReloadOptions, parseConstructorOptions, parseLoadVideoOptions, } from "./option_utils"; import { constructPlayerStateReference, emitPlayPauseEvents, emitSeekEvents, isLoadedState, PLAYER_STATES, } from "./utils"; /* eslint-disable @typescript-eslint/naming-convention */ // Enable debug mode as soon as `RX_PLAYER_DEBUG_MODE__` is set to `true`: const globals: typeof globalScope & { __RX_PLAYER_DEBUG_MODE__?: boolean; } = globalScope; let isDebugModeEnabled: boolean = typeof globals.__RX_PLAYER_DEBUG_MODE__ === "boolean" && globals.__RX_PLAYER_DEBUG_MODE__; try { Object.defineProperty(globals, "__RX_PLAYER_DEBUG_MODE__", { get(): boolean { return isDebugModeEnabled; }, set(val: boolean) { isDebugModeEnabled = val; if (val) { Player.LogLevel = "DEBUG"; Player.LogFormat = "full"; } }, }); } catch (_err) { // Ignore, maybe we're in some jsdom thing, maybe the current target does not // authorize setting globals that way etc. } if (isDebugModeEnabled) { log.setLevel("DEBUG", "full"); } else if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) { log.setLevel(__LOGGER_LEVEL__.CURRENT_LEVEL, "standard"); } const generateContentId = idGenerator(); /** * Options of a `loadVideo` call which are for now not supported when running * in a "multithread" mode. * * TODO support those? */ const MULTI_THREAD_UNSUPPORTED_LOAD_VIDEO_OPTIONS = [ "manifestLoader", "segmentLoader", ] as const; /** * @class Player * @extends EventEmitter */ class Player extends EventEmitter<IPublicAPIEvent> { /** Current version of the RxPlayer. */ public static version: string; /** Current version of the RxPlayer. */ public readonly version: string; /** * Store all video elements currently in use by an RxPlayer instance. * This is used to check that a video element is not shared between multiple instances. * Use of a WeakSet ensure the object is garbage collected if it's not used anymore. */ private static _priv_currentlyUsedVideoElements = new WeakSet<IMediaElement>(); /** * Media element attached to the RxPlayer. * Set to `null` when the RxPlayer is disposed. */ public videoElement: IMediaElement | null; // null on dispose /** Logger the RxPlayer uses. */ public readonly log: Logger; /** * Current state of the RxPlayer. * Please use `getPlayerState()` instead. */ public state: IPlayerState; /** * Emit when the the RxPlayer is not needed anymore and thus all resources * used for its normal functionment can be freed. * The player will be unusable after that. */ private readonly _destroyCanceller: TaskCanceller; /** * Contains `true` when the previous content is cleaning-up, `false` when it's * done. * A new content cannot be launched until it stores `false`. */ private readonly _priv_contentLock: SharedReference<boolean>; /** * The speed that should be applied to playback. * Used instead of videoElement.playbackRate to allow more flexibility. */ private readonly _priv_speed: SharedReference<number>; /** Store buffer-related options used needed when initializing a content. */ private readonly _priv_bufferOptions: { /** Last wanted buffer goal. */ wantedBufferAhead: SharedReference<number>; /** Maximum kept buffer ahead in the current position, in seconds. */ maxBufferAhead: SharedReference<number>; /** Maximum kept buffer behind in the current position, in seconds. */ maxBufferBehind: SharedReference<number>; /** Maximum size of video buffer , in kiloBytes */ maxVideoBufferSize: SharedReference<number>; }; /** Information on the current bitrate settings. */ private readonly _priv_bitrateInfos: { /** * Store last bitrates for each media type for the adaptive logic. * Store the initial wanted bitrates at first. */ lastBitrates: { audio?: number; video?: number; text?: number }; }; private _priv_worker: Worker | null; /** * Current fatal error which STOPPED the player. * `null` if no fatal error was received for the current or last content. */ private _priv_currentError: Error | null; /** * Information about the current content being played. * `null` when no content is currently loading or loaded. */ private _priv_contentInfos: IPublicApiContentInfos | null; /** If `true` trickMode video tracks will be chosen if available. */ private _priv_preferTrickModeTracks: boolean; /** Refer to last picture in picture event received. */ private _priv_pictureInPictureRef: IReadOnlySharedReference<IPictureInPictureEvent>; /** Store wanted configuration for the `videoResolutionLimit` option. */ private readonly _priv_videoResolutionLimit: "videoElement" | "screen" | "none"; /** Store wanted configuration for the `throttleVideoBitrateWhenHidden` option. */ private readonly _priv_throttleVideoBitrateWhenHidden: boolean; /** * Store last state of various values sent as events, to avoid re-triggering * them multiple times in a row. * * All those events are linked to the content being played and can be cleaned * on stop. */ private _priv_contentEventsMemory: { [P in keyof IPublicAPIEvent]?: IPublicAPIEvent[P]; }; /** * Information that can be relied on once `reload` is called. * It should refer to the last content being played. */ private _priv_reloadingMetadata: { /** * `loadVideo` options communicated for the last content that will be re-used * on reload. */ options?: IParsedLoadVideoOptions; /** * Manifest loaded for the last content that should be used once `reload` * is called. */ manifest?: IManifest; /** * If `true`, the player should be paused after reloading. * If `false`, the player should be playing after reloading. * If `undefined`, `reload` should depend on other criteria (such as the * `autoPlay` option, to know whether the content should play or not after * reloading. */ reloadInPause?: boolean; /** * If set this is the position that should be seeked to by default after * reloading. */ reloadPosition?: number; }; /** * Store last value of autoPlay, from the last load or reload. */ private _priv_lastAutoPlay: boolean; /** All possible Error types emitted by the RxPlayer. */ static get ErrorTypes(): Record<IErrorType, IErrorType> { return ErrorTypes; } /** All possible Error codes emitted by the RxPlayer. */ static get ErrorCodes(): Record<IErrorCode, IErrorCode> { return ErrorCodes; } /** * Current log level. * Update current log level. * Should be either (by verbosity ascending): * - "NONE" * - "ERROR" * - "WARNING" * - "INFO" * - "DEBUG" * Any other value will be translated to "NONE". */ static get LogLevel(): string { return log.getLevel(); } static set LogLevel(logLevel: string) { log.setLevel(logLevel, log.getFormat()); } /** * Current log format. * Should be either (by verbosity ascending): * - "standard": Regular log messages. * - "full": More verbose format, including a timestamp and a namespace. * Any other value will be translated to "standard". */ static get LogFormat(): string { return log.getFormat(); } static set LogFormat(format: string) { log.setLevel(log.getLevel(), format); } /** * Add feature(s) to the RxPlayer. * @param {Array.<Object>} featureList - Features wanted. */ static addFeatures(featureList: IFeature[]): void { addFeatures(featureList); } /** * Register the video element to the set of elements currently in use. * @param videoElement the video element to register. * @throws Error - Throws if the element is already used by another player instance. */ private static _priv_registerVideoElement(videoElement: IMediaElement) { if (Player._priv_currentlyUsedVideoElements.has(videoElement)) { const errorMessage = "The video element is already attached to another RxPlayer instance." + "\nMake sure to dispose the previous instance with player.dispose() before creating" + " a new player instance attaching that video element."; // eslint-disable-next-line no-console console.warn(errorMessage); /* * TODO: for next major version 5.0: this need to throw an error instead of just logging * this was not done for minor version as it could be considerated a breaking change. * * throw new Error(errorMessage); */ } Player._priv_currentlyUsedVideoElements.add(videoElement); } /** * Deregister the video element of the set of elements currently in use. * @param videoElement the video element to deregister. */ static _priv_deregisterVideoElement(videoElement: IMediaElement) { if (Player._priv_currentlyUsedVideoElements.has(videoElement)) { Player._priv_currentlyUsedVideoElements.delete(videoElement); } } /** * @constructor * @param {Object} options */ constructor(options: IConstructorOptions = {}) { super(); const { baseBandwidth, videoResolutionLimit, maxBufferAhead, maxBufferBehind, throttleVideoBitrateWhenHidden, videoElement, wantedBufferAhead, maxVideoBufferSize, } = parseConstructorOptions(options); // Workaround to support Firefox autoplay on FF 42. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1194624 videoElement.preload = "auto"; this.version = /* PLAYER_VERSION */ "4.4.1"; this.log = log; this.state = "STOPPED"; this.videoElement = videoElement; Player._priv_registerVideoElement(this.videoElement); const destroyCanceller = new TaskCanceller(); this._destroyCanceller = destroyCanceller; this._priv_pictureInPictureRef = getPictureOnPictureStateRef( videoElement, destroyCanceller.signal, ); this._priv_speed = new SharedReference( videoElement.playbackRate, this._destroyCanceller.signal, ); this._priv_preferTrickModeTracks = false; this._priv_contentLock = new SharedReference<boolean>( false, this._destroyCanceller.signal, ); this._priv_bufferOptions = { wantedBufferAhead: new SharedReference( wantedBufferAhead, this._destroyCanceller.signal, ), maxBufferAhead: new SharedReference(maxBufferAhead, this._destroyCanceller.signal), maxBufferBehind: new SharedReference( maxBufferBehind, this._destroyCanceller.signal, ), maxVideoBufferSize: new SharedReference( maxVideoBufferSize, this._destroyCanceller.signal, ), }; this._priv_bitrateInfos = { lastBitrates: { audio: baseBandwidth, video: baseBandwidth }, }; this._priv_throttleVideoBitrateWhenHidden = throttleVideoBitrateWhenHidden; this._priv_videoResolutionLimit = videoResolutionLimit; this._priv_currentError = null; this._priv_contentInfos = null; this._priv_contentEventsMemory = {}; this._priv_reloadingMetadata = {}; this._priv_lastAutoPlay = false; this._priv_worker = null; const onVolumeChange = () => { this.trigger("volumeChange", { volume: videoElement.volume, muted: videoElement.muted, }); }; videoElement.addEventListener("volumechange", onVolumeChange); destroyCanceller.signal.register(() => { videoElement.removeEventListener("volumechange", onVolumeChange); }); } /** * TODO returns promise? * @param {Object} workerSettings */ public attachWorker(workerSettings: IWorkerSettings): Promise<void> { return new Promise((res, rej) => { if (!hasWorkerApi()) { log.warn("API", "Cannot rely on a WebWorker: Worker API unavailable"); return rej( new WorkerInitializationError("INCOMPATIBLE_ERROR", "Worker unavailable"), ); } // check if the user already attach worker before // terminate the previous worker to release the resources if (this._priv_worker !== null) { if (this.state !== "STOPPED") { log.warn( "API", "Cannot attach a new worker while a content is playing, please stop the player first.", ); return rej( new WorkerInitializationError( "SETUP_ERROR", "Cannot attach a new worker while a content is playing", ), ); } else { this._priv_worker.terminate(); } } if (typeof workerSettings.workerUrl === "string") { this._priv_worker = new Worker(workerSettings.workerUrl); } else { const blobUrl = URL.createObjectURL(workerSettings.workerUrl); this._priv_worker = new Worker(blobUrl); URL.revokeObjectURL(blobUrl); } this._priv_worker.onerror = (evt: ErrorEvent) => { if (this._priv_worker !== null) { this._priv_worker.terminate(); this._priv_worker = null; } log.error( "API", "Unexpected worker error", evt.error instanceof Error ? evt.error : undefined, ); rej( new WorkerInitializationError( "UNKNOWN_ERROR", 'Unexpected Worker "error" event', ), ); }; const handleInitMessages = (msg: MessageEvent) => { const msgData = msg.data as unknown as IWorkerMessage; if (msgData.type === WorkerMessageType.InitError) { log.warn("API", "Processing InitError worker message: detaching worker"); if (this._priv_worker !== null) { this._priv_worker.removeEventListener("message", handleInitMessages); this._priv_worker.terminate(); this._priv_worker = null; } rej( new WorkerInitializationError( "SETUP_ERROR", "Worker parser initialization failed: " + msgData.value.errorMessage, ), ); } else if (msgData.type === WorkerMessageType.InitSuccess) { log.info("API", "InitSuccess received from worker."); if (this._priv_worker !== null) { this._priv_worker.removeEventListener("message", handleInitMessages); } res(); } }; this._priv_worker.addEventListener("message", handleInitMessages); log.debug("M-->C", "Sending message", { name: MainThreadMessageType.Init }); this._priv_worker.postMessage({ type: MainThreadMessageType.Init, value: { dashWasmUrl: workerSettings.dashWasmUrl, logLevel: log.getLevel(), logFormat: log.getFormat(), sendBackLogs: isDebugModeEnabled, date: Date.now(), timestamp: getMonotonicTimeStamp(), hasVideo: this.videoElement?.nodeName.toLowerCase() === "video", }, }); log.addEventListener( "onLogLevelChange", (logInfo) => { if (this._priv_worker === null) { return; } log.debug("M-->C", "Sending message", { name: MainThreadMessageType.LogLevelUpdate, }); this._priv_worker.postMessage({ type: MainThreadMessageType.LogLevelUpdate, value: { logLevel: logInfo.level, logFormat: logInfo.format, sendBackLogs: isDebugModeEnabled, }, }); }, this._destroyCanceller.signal, ); const sendConfigUpdates = (updates: Partial<IDefaultConfig>) => { if (this._priv_worker === null) { return; } log.debug("M-->C", "Sending message:", { name: MainThreadMessageType.ConfigUpdate, }); this._priv_worker.postMessage({ type: MainThreadMessageType.ConfigUpdate, value: updates, }); }; if (config.updated) { sendConfigUpdates(config.getCurrent()); } config.addEventListener("update", sendConfigUpdates, this._destroyCanceller.signal); }); } /** * Returns information on which "mode" the RxPlayer is running for the current * content (e.g. main logic running in a WebWorker or not, are we in * directfile mode...). * * Returns `null` if no content is loaded. * @returns {Object|null} */ public getCurrentModeInformation(): IModeInformation | null { if (this._priv_contentInfos === null) { return null; } return { isDirectFile: this._priv_contentInfos.isDirectFile, useWorker: this._priv_contentInfos.useWorker, }; } /** * Register a new callback for a player event event. * * @param {string} evt - The event to register a callback to * @param {Function} fn - The callback to call as that event is triggered. * The callback will take as argument the eventual payload of the event * (single argument). */ addEventListener<TEventName extends keyof IPublicAPIEvent>( evt: TEventName, fn: IListener<IPublicAPIEvent, TEventName>, ): void { // The EventEmitter's `addEventListener` method takes an optional third // argument that we do not want to expose in the public API. // We thus overwrite that function to remove any possible usage of that // third argument. return super.addEventListener(evt, fn); } /** * Stop the playback for the current content. */ stop(): void { if (this._priv_contentInfos !== null) { this._priv_contentInfos.currentContentCanceller.cancel(); } this._priv_cleanUpCurrentContentState(); if (this.state !== PLAYER_STATES.STOPPED) { this._priv_setPlayerState(PLAYER_STATES.STOPPED); } } /** * Free the resources used by the player. * /!\ The player cannot be "used" anymore after this method has been called. */ dispose(): void { // free resources linked to the loaded content this.stop(); if (this.videoElement !== null) { Player._priv_deregisterVideoElement(this.videoElement); // free resources used for decryption management disposeDecryptionResources(this.videoElement).catch((err: unknown) => { const message = err instanceof Error ? err.message : "Unknown error"; log.error("API", "Could not dispose decryption resources: " + message); }); } // free resources linked to the Player instance this._destroyCanceller.cancel(); this._priv_reloadingMetadata = {}; // un-attach video element this.videoElement = null; if (this._priv_worker !== null) { this._priv_worker.terminate(); this._priv_worker = null; } } /** * Load a new video. * @param {Object} opts */ loadVideo(opts: ILoadVideoOptions): void { const options = parseLoadVideoOptions(opts); log.info("API", "Calling loadvideo", { url: options.url, transport: options.transport, }); this._priv_reloadingMetadata = { options }; this._priv_initializeContentPlayback(options); this._priv_lastAutoPlay = options.autoPlay; } /** * Reload the last loaded content. * @param {Object} reloadOpts */ reload(reloadOpts?: { reloadAt?: { position?: number; relative?: number }; keySystems?: IKeySystemOption[]; autoPlay?: boolean; }): void { const { options, manifest, reloadPosition, reloadInPause } = this._priv_reloadingMetadata; if (options === undefined) { throw new Error("API: Can't reload without having previously loaded a content."); } checkReloadOptions(reloadOpts); let startAt: IParsedStartAtOption | undefined; if (reloadOpts?.reloadAt?.position !== undefined) { startAt = { position: reloadOpts.reloadAt.position }; } else if (reloadOpts?.reloadAt?.relative !== undefined) { if (reloadPosition === undefined) { throw new Error( "Can't reload to a relative position when previous content was not loaded.", ); } else { startAt = { position: reloadOpts.reloadAt.relative + reloadPosition }; } } else if (reloadPosition !== undefined) { startAt = { position: reloadPosition }; } let autoPlay: boolean | undefined; if (reloadOpts?.autoPlay !== undefined) { autoPlay = reloadOpts.autoPlay; } else if (reloadInPause !== undefined) { autoPlay = !reloadInPause; } let keySystems: IKeySystemOption[] | undefined; if (reloadOpts?.keySystems !== undefined) { keySystems = reloadOpts.keySystems; } else if (this._priv_reloadingMetadata.options?.keySystems !== undefined) { keySystems = this._priv_reloadingMetadata.options.keySystems; } const newOptions = { ...options, initialManifest: manifest }; if (startAt !== undefined) { newOptions.startAt = startAt; } if (autoPlay !== undefined) { newOptions.autoPlay = autoPlay; } if (keySystems !== undefined) { newOptions.keySystems = keySystems; } this._priv_initializeContentPlayback(newOptions); } public createDebugElement(element: HTMLElement): { dispose(): void; } { if (features.createDebugElement === null) { throw new Error("Feature `DEBUG_ELEMENT` not added to the RxPlayer"); } const canceller = new TaskCanceller(); features.createDebugElement(element, this, canceller.signal); return { dispose() { canceller.cancel(); }, }; } /** * Returns an array decribing the various thumbnail tracks that can be * encountered at the wanted time or Period. * @param {Object} arg * @param {number|undefined} [arg.time] - The position to check for thumbnail * tracks, in seconds. * @param {string|undefined} [arg.periodId] - The Period to check for * thumbnail tracks. * If not set and if `arg.time` is also not set, the current Period will be * considered. * @returns {Array.<Object>} */ public getAvailableThumbnailTracks({ time, periodId, }: { time?: number | undefined; periodId?: string | undefined; } = {}): IThumbnailTrackInfo[] { if (this._priv_contentInfos === null || this._priv_contentInfos.manifest === null) { return []; } const { manifest } = this._priv_contentInfos; let period; if (time !== undefined) { period = getPeriodForTime(this._priv_contentInfos.manifest, time); if (period === undefined || period.thumbnailTracks.length === 0) { return []; } } else if (periodId !== undefined) { period = arrayFind(manifest.periods, (p) => p.id === periodId); if (period === undefined) { log.error("API", "getAvailableThumbnailTracks: periodId not found", { periodId }); return []; } } else { const { currentPeriod } = this._priv_contentInfos; if (currentPeriod === null) { return []; } period = currentPeriod; } return period.thumbnailTracks.map((t) => { return { id: t.id, width: Math.floor(t.width / t.horizontalTiles), height: Math.floor(t.height / t.verticalTiles), mimeType: t.mimeType, }; }); } /** * Render inside the given `container` the thumbnail corresponding to the * given time. * * If no thumbnail is available at that time or if the RxPlayer does not succeed * to load or render it, reject the corresponding Promise and remove the * potential previous thumbnail from the container. * * If a new `renderThumbnail` call is made with the same `container` before it * had time to finish, the Promise is also rejected but the previous thumbnail * potentially found in the container is untouched. * * @param {Object|undefined} options * @returns {Promise} */ public async renderThumbnail(options: IThumbnailRenderingOptions): Promise<void> { if (isNullOrUndefined(options.time)) { throw new Error( "You have to provide a `time` property to `renderThumbnail`, indicating the wanted thumbnail time in seconds.", ); } if (isNullOrUndefined(options.container)) { throw new Error( "You have to provide a `container` property to `renderThumbnail`, specifying the HTML Element in which the thumbnail should be inserted.", ); } return renderThumbnail(this._priv_contentInfos, options); } /** * From given options, initialize content playback. * @param {Object} options */ private _priv_initializeContentPlayback(options: IParsedLoadVideoOptions): void { const { autoPlay, cmcd, defaultAudioTrackSwitchingMode, enableFastSwitching, initialManifest, keySystems, lowLatencyMode, minimumManifestUpdateInterval, requestConfig, onCodecSwitch, startAt, transport, checkMediaSegmentIntegrity, checkManifestIntegrity, manifestLoader, referenceDateTime, segmentLoader, serverSyncInfos, mode, experimentalOptions, __priv_manifestUpdateUrl, __priv_patchLastSegmentInSidx, url, onAudioTracksNotPlayable, onVideoTracksNotPlayable, } = options; // Perform multiple checks on the given options if (this.videoElement === null) { throw new Error("the attached video element is disposed"); } const isDirectFile = transport === "directfile"; /** Emit to stop the current content. */ const currentContentCanceller = new TaskCanceller(); const videoElement = this.videoElement; let initializer: ContentInitializer; let useWorker = false; let mediaElementTracksStore: IMediaElementTracksStore | null = null; if (!isDirectFile) { /** Interface used to load and refresh the Manifest. */ const manifestRequestSettings = { lowLatencyMode, maxRetry: requestConfig.manifest?.maxRetry, requestTimeout: requestConfig.manifest?.timeout, connectionTimeout: requestConfig.manifest?.connectionTimeout, minimumManifestUpdateInterval, initialManifest, }; const relyOnVideoVisibilityAndSize = canRelyOnVideoVisibilityAndSize(); const throttlers: IABRThrottlers = { throttleBitrate: {}, limitResolution: {}, }; if (this._priv_throttleVideoBitrateWhenHidden) { if (!relyOnVideoVisibilityAndSize) { log.warn( "API", "Can't apply throttleVideoBitrateWhenHidden because " + "browser can't be trusted for visibility.", ); } else { throttlers.throttleBitrate = { video: createMappedReference( getVideoVisibilityRef( this._priv_pictureInPictureRef, currentContentCanceller.signal, ), (isActive) => (isActive ? Infinity : 0), currentContentCanceller.signal, ), }; } } if (this._priv_videoResolutionLimit === "videoElement") { if (!relyOnVideoVisibilityAndSize) { log.warn( "API", "Can't apply videoResolutionLimit because browser can't be " + "trusted for video size.", ); } else { throttlers.limitResolution = { video: getElementResolutionRef( videoElement, this._priv_pictureInPictureRef, currentContentCanceller.signal, ), }; } } else if (this._priv_videoResolutionLimit === "screen") { throttlers.limitResolution = { video: getScreenResolutionRef(currentContentCanceller.signal), }; } /** Options used by the adaptive logic. */ const adaptiveOptions = { initialBitrates: this._priv_bitrateInfos.lastBitrates, lowLatencyMode, throttlers, }; /** Options used by the TextTrack SegmentSink. */ const textTrackOptions = options.textTrackMode === "native" ? { textTrackMode: "native" as const } : { textTrackMode: "html" as const, textTrackElement: options.textTrackElement, }; const bufferOptions = objectAssign( { enableFastSwitching, onCodecSwitch }, this._priv_bufferOptions, ); const segmentRequestOptions = { lowLatencyMode, maxRetry: requestConfig.segment?.maxRetry, requestTimeout: requestConfig.segment?.timeout, connectionTimeout: requestConfig.segment?.connectionTimeout, }; const canRunInMultiThread = features.multithread !== null && this._priv_worker !== null && this.videoElement.FORCED_MEDIA_SOURCE === undefined && transport === "dash" && MULTI_THREAD_UNSUPPORTED_LOAD_VIDEO_OPTIONS.every((option) => isNullOrUndefined(options[option]), ) && typeof options.representationFilter !== "function"; if (mode === "main" || (mode === "auto" && !canRunInMultiThread)) { if (features.mainThreadMediaSourceInit === null) { throw new Error( "Cannot load video, neither in a WebWorker nor with the " + "`MEDIA_SOURCE_MAIN` feature", ); } const transportFn = features.transports[transport]; if (typeof transportFn !== "function") { // Stop previous content and reset its state this.stop(); this._priv_currentError = null; throw new Error(`transport "${transport}" not supported`); } const representationFilter = typeof options.representationFilter === "string" ? createRepresentationFilterFromFnString(options.representationFilter) : options.representationFilter; log.info("API", "Initializing MediaSource mode in the main thread"); const transportPipelines = transportFn({ lowLatencyMode, checkMediaSegmentIntegrity, checkManifestIntegrity, manifestLoader, referenceDateTime, representationFilter, segmentLoader, serverSyncInfos, __priv_manifestUpdateUrl, __priv_patchLastSegmentInSidx, }); initializer = new features.mainThreadMediaSourceInit({ adaptiveOptions, autoPlay, bufferOptions, cmcd, enableRepresentationAvoidance: experimentalOptions.enableRepresentationAvoidance, keySystems, lowLatencyMode, transport: transportPipelines, manifestRequestSettings, segmentRequestOptions, speed: this._priv_speed, startAt, textTrackOptions, url, }); } else { if (features.multithread === null) { throw new Error( "Cannot load video in multithread mode: `MULTI_THREAD` " + "feature not imported.", ); } else if (this._priv_worker === null) { throw new Error( "Cannot load video in multithread mode: `attachWorker` " + "method not called.", ); } assert(typeof options.representationFilter !== "function"); useWorker = true; log.info("API", "Initializing MediaSource mode in a WebWorker"); const transportOptions = { lowLatencyMode, checkMediaSegmentIntegrity, checkManifestIntegrity, referenceDateTime, serverSyncInfos, manifestLoader: undefined, segmentLoader: undefined, representationFilter: options.representationFilter, __priv_manifestUpdateUrl, __priv_patchLastSegmentInSidx, }; initializer = new features.multithread.init({ adaptiveOptions, autoPlay, bufferOptions, cmcd, enableRepresentationAvoidance: experimentalOptions.enableRepresentationAvoidance, keySystems, lowLatencyMode, transportOptions, manifestRequestSettings, segmentRequestOptions, speed: this._priv_speed, startAt, textTrackOptions, worker: this._priv_worker, url, useMseInWorker: hasMseInWorker, }); } } else { if (features.directfile === null) { this.stop(); this._priv_currentError = null; throw new Error("DirectFile feature not activated in your build."); } else if (isNullOrUndefined(url)) { throw new Error("No URL for a DirectFile content"); } log.info("API", "Initializing DirectFile mode in the main thread"); mediaElementTracksStore = this._priv_initializeMediaElementTracksStore( currentContentCanceller.signal, ); if (currentContentCanceller.isUsed()) { return; } initializer = new features.directfile.initDirectFile({ autoPlay, keySystems, speed: this._priv_speed, startAt, url, }); } /** Global "playback observer" which will emit playback conditions */ const playbackObserver = new MediaElementPlaybackObserver({ withMediaSource: !isDirectFile, lowLatencyMode, }); /* * We want to block seeking operations until we know the media element is * ready for it. */ playbackObserver.blockSeeking(); currentContentCanceller.signal.register(() => { playbackObserver.stop(); }); /** Future `this._priv_contentInfos` related to this content. */ const contentInfos: IPublicApiContentInfos = { contentId: generateContentId(), originalUrl: url, playbackObserver, currentContentCanceller, defaultAudioTrackSwitchingMode, initializer, isDirectFile, manifest: null, currentPeriod: null, activeAdaptations: null, activeRepresentations: null, tracksStore: null, mediaElementTracksStore, useWorker, segmentSinkMetricsCallback: null, fetchThumbnailDataCallback: null, thumbnailRequestsInfo: { pendingRequests: new WeakMap(), lastResponse: null, }, onAudioTracksNotPlayable, onVideoTracksNotPlayable, }; // Bind events initializer.addEventListener("error", (error) => { this._priv_onFatalError(error, contentInfos); }); initializer.addEventListener("warning", (error) => { const formattedError = formatError(error, { defaultCode: "NONE", defaultReason: "An unknown error happened.", }); log.warn("API", "Sending warning:", formattedError); this.trigger("warning", formattedError); }); initializer.addEventListener("reloadingMediaSource", (payload) => { if (contentInfos.tracksStore !== null) { contentInfos.tracksStore.resetPeriodObjects(); } if (this._priv_contentInfos !== null) { this._priv_contentInfos.segmentSinkMetricsCallback = null; } this._priv_lastAutoPlay = payload.autoPlay; }); initializer.addEventListener("inbandEvents", (inbandEvents) => this.trigger("inbandEvents", inbandEvents), ); initializer.addEventListener("streamEvent", (streamEvent) => this.trigger("streamEvent", streamEvent), ); initializer.addEventListener("streamEventSkip", (streamEventSkip) => this.trigger("streamEventSkip", streamEventSkip), ); initializer.addEventListener("activePeriodChanged", (periodInfo) => this._priv_onActivePeriodChanged(contentInfos, periodInfo), ); initializer.addEventListener("periodStreamReady", (periodReadyInfo) => this._priv_onPeriodStreamReady(contentInfos, periodReadyInfo), ); initializer.addEventListener("periodStreamCleared", (periodClearedInfo) => this._priv_onPeriodStreamCleared(contentInfos, periodClearedInfo), ); initializer.addEventListener("representationChange", (representationInfo) => this._priv_onRepresentationChange(contentInfos, representationInfo), ); initializer.addEventListener("adaptationChange", (adaptationInfo) => this._priv_onAdaptationChange(contentInfos, adaptationInfo), ); initializer.addEventListener("bitrateEstimateChange", (bitrateEstimateInfo) => this._priv_onBitrateEstimateChange(bitrateEstimateInfo), ); initializer.addEventListener("manifestReady", (manifest) => this._priv_onManifestReady(contentInfos, manifest), ); initializer.addEventListener("manifestUpdate", (updates) => this._priv_onManifestUpdate(contentInfos, updates), ); initializer.addEventListener("codecSupportUpdate", () => this._priv_onCodecSupportUpdate(contentInfos), ); initializer.addEventListener("decipherabilityUpdate", (updates) => this._priv_onDecipherabilityUpdate(contentInfos, updates), ); initializer.addEventListener("loaded", (evt) => { if (this._priv_contentInfos !== null) { this._priv_contentInfos.segmentSinkMetricsCallback = evt.getSegmentSinkMetrics; this._priv_contentInfos.fetchThumbnailDataCallback = evt.getThumbnailData; } }); // Now, that most events are linked, prepare the next content. initializer.prepare(); // Now that the content is prepared, stop previous content and reset state // This is done after content preparation as `stop` could technically have // a long and synchronous blocking time. // Note that this call is done **synchronously** after all events linking. // This is **VERY** important so: // - the `STOPPED` state is switched to synchronously after loading a new // content. // - we can avoid involontarily catching events linked to the previous // content. this.stop(); playbackObserver.attachMediaElement(videoElement); // Update the RxPlayer's state at the right events const playerStateRef = constructPlayerStateReference( initializer, videoElement, playbackObserver, isDirectFile, currentContentCanceller.signal, ); currentContentCanceller.signal.register(() => { initializer.dispose(); }); /** * Function updating `this._priv_reloadingMetadata` in function of the * current state and playback conditions. * To call when either might change. * @param {string} state - The player state we're about to switch to. */ const updateReloadingMetadata = (state: IPlayerState) => { switch (state) { case "STOPPED": case "RELOADING": case "LOADING": break; // keep previous metadata case "ENDED": this._priv_reloadingMetadata.reloadInPause = true; this._priv_reloadingMetadata.reloadPosition = playbackObserver .getReference() .getValue() .position.getPolled(); break; default: { const o = playbackObserver.getReference().getValue(); this._priv_reloadingMetadata.reloadInPause = o.paused; this._priv_reloadingMetadata.reloadPosition = o.position.getWanted(); break; } } }; /** * `TaskCanceller` allowing to stop emitting `"play"` and `"pause"` * events. * `null` when such events are not emitted currently. */ let playPauseEventsCanceller: TaskCanceller | null = null; /** * Callback emitting `"play"` and `"pause`" events once the content is * loaded, starting from the state indicated in argument. * @param {boolean} willAutoPlay - If `false`, we're currently paused. */ const triggerPlayPauseEventsWhenReady = (willAutoPlay: boolean) => { if (playPauseEventsCanceller !== null) { playPauseEventsCanceller.cancel(); // cancel previous logic playPauseEventsCanceller = null; } playerStateRef.onUpdate( (val, stopListeningToStateUpdates) => { if (!isLoadedState(val)) { return; // content not loaded yet: no event } stopListeningToStateUpdates(); if (playPauseEventsCanceller !== null) { playPauseEventsCanceller.cancel(); } playPauseEventsCanceller = new TaskCanceller(); playPauseEventsCanceller.linkToSignal(currentContentCanceller.signal); if (willAutoPlay !== !videoElement.paused) { // paused status is not at the expected value on load: emit event if (videoElement.paused) { this.trigger("pause", null); } else { this.trigger("play", null); } } emitPlayPauseEvents( videoElement, () => this.trigger("play", null), () => this.trigger("pause", null), currentContentCanceller.signal, ); }, { emitCurrentValue: false, clearSignal: currentContentCanceller.signal, }, ); }; triggerPlayPauseEventsWhenReady(autoPlay); initializer.addEventListener("reloadingMediaSource", (payload) => { triggerPlayPauseEventsWhenReady(payload.autoPlay); }); this._priv_currentError = null; this._priv_contentInfos = contentInfos; /** * `TaskCanceller` allowing to stop emitting `"seeking"` and `"seeked"` * events. * `null` when such events are not emitted currently. */ let seekEventsCanceller: TaskCanceller | null = null; // React to player state change playerStateRef.onUpdate( (newState: IPlayerState) => { updateReloadingMetadata(newState); this._priv_setPlayerState(newState); if (currentContentCanceller.isUsed()) { return; } if (seekEventsCanceller !== null) { if (!isLoadedState(this.state)) { seekEventsCanceller.cancel(); seekEventsCanceller = null; } } else if (isLoadedState(this.state)) { seekEventsCanceller = new TaskCanceller(); seekEventsCanceller.linkToSignal(currentContentCanceller.signal); emitSeekEvents( playbackObserver, () => this.trigger("seeking", null), () => this.trigger("seeked", null), seekEventsCanceller.signal, ); } }, { emitCurrentValue: true, clearSignal: currentContentCanceller.signal }, ); // React to playback conditions change playbackObserver.listen( (observation) => { updateReloadingMetadata(this.state); this._priv_triggerPositionUpdate(contentInfos, observation); }, { clearSignal: currentContentCanceller.signal }, ); currentContentCanceller.signal.register(() => { initializer.removeEventListener(); }); // initialize the content only when the lock is inactive this._priv_contentLock.onUpdate( (isLocked, stopListeningToLock) => { if (!isLocked) { stopListeningToLock(); // start playback! initializer.start(videoElement, playbackObserver); } }, { emitCurrentValue: true, clearSignal: currentContentCanceller.signal }, ); } /** * Returns fatal error if one for the current content. * null otherwise. * @returns {Object|null} - The current Error (`null` when no error). */ getError(): Error | null { return this._priv_currentError; } /** * Returns the media DOM element used by the player. * You should not its HTML5 API directly and use the player's method instead, * to ensure a well-behaved player. * @returns {HTMLMediaElement|null} - The HTMLMediaElement used (`null` when * disposed) */ // eslint-disable-next-line @typescript-eslint/no-restricted-types getVideoElement(): HTMLMediaElement | null { // eslint-disable-next-line @typescript-eslint/no-restricted-types return this.videoElement as HTMLMediaElement; } /** * Returns the player's current state. * @returns {string} - The current Player's state */ getPlayerState(): string { return this.state; } /** * Returns true if a content is loaded. * @returns {Boolean} - `true` if a content is loaded, `false` otherwise. */ isContentLoaded(): boolean { return !arrayIncludes(["LOADING", "RELOADING", "STOPPED"], this.state); } /** * Returns true if the player is buffering. * @returns {Boolean} - `true` if the player is buffering, `false` otherwise. */ isBuffering(): boolean { return arrayIncludes(["BUFFERING", "SEEKING", "LOA