rx-player
Version:
Canal+ HTML5 Video Player
1,111 lines • 112 kB
JavaScript
/**
* 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.
*/
import canRelyOnVideoVisibilityAndSize from "../../compat/can_rely_on_video_visibility_and_size";
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 isDebugModeEnabled from "../../compat/is_debug_mode_enabled";
import config from "../../config";
import { ErrorCodes, ErrorTypes, formatError, MediaError } from "../../errors";
import WorkerInitializationError from "../../errors/worker_initialization_error";
import features, { addFeatures } from "../../features";
import log from "../../log";
import { getLivePosition, getMaximumSafePosition, getMinimumSafePosition, createRepresentationFilterFromFnString, getPeriodForTime, } from "../../manifest";
import MediaElementPlaybackObserver from "../../playback_observer/media_element_playback_observer";
import arrayFind from "../../utils/array_find";
import arrayIncludes from "../../utils/array_includes";
import assert, { assertUnreachable } from "../../utils/assert";
import EventEmitter from "../../utils/event_emitter";
import idGenerator from "../../utils/id_generator";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import getMonotonicTimeStamp from "../../utils/monotonic_timestamp";
import objectAssign from "../../utils/object_assign";
import { getLeftSizeOfBufferedTimeRange } from "../../utils/ranges";
import SharedReference, { createMappedReference } from "../../utils/reference";
import TaskCanceller from "../../utils/task_canceller";
import { clearOnStop, disposeDecryptionResources, getKeySystemConfiguration, } from "../decrypt";
import renderThumbnail from "../render_thumbnail";
import TracksStore from "../tracks_store";
import { checkReloadOptions, parseConstructorOptions, parseLoadVideoOptions, } from "./option_utils";
import { constructPlayerStateReference, emitPlayPauseEvents, emitSeekEvents, isLoadedState, } from "./utils";
/* eslint-disable @typescript-eslint/naming-convention */
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",
];
/**
* @class Player
* @extends EventEmitter
*/
class Player extends EventEmitter {
/** All possible Error types emitted by the RxPlayer. */
static get ErrorTypes() {
return ErrorTypes;
}
/** All possible Error codes emitted by the RxPlayer. */
static get ErrorCodes() {
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() {
return log.getLevel();
}
static set LogLevel(logLevel) {
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() {
return log.getFormat();
}
static set LogFormat(format) {
log.setLevel(log.getLevel(), format);
}
/**
* Add feature(s) to the RxPlayer.
* @param {Array.<Object>} featureList - Features wanted.
*/
static addFeatures(featureList) {
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.
*/
static _priv_registerVideoElement(videoElement) {
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) {
if (Player._priv_currentlyUsedVideoElements.has(videoElement)) {
Player._priv_currentlyUsedVideoElements.delete(videoElement);
}
}
/**
* @constructor
* @param {Object} options
*/
constructor(options = {}) {
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.3.0";
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(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
*/
attachWorker(workerSettings) {
return new Promise((res, rej) => {
var _a;
if (!hasWorkerApi()) {
log.warn("API: Cannot rely on a WebWorker: Worker API unavailable");
return rej(new WorkerInitializationError("INCOMPATIBLE_ERROR", "Worker unavailable"));
}
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) => {
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) => {
const msgData = msg.data;
if (msgData.type === "init-error" /* 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 === "init-success" /* 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("---> Sending To Worker:", "init" /* MainThreadMessageType.Init */);
this._priv_worker.postMessage({
type: "init" /* MainThreadMessageType.Init */,
value: {
dashWasmUrl: workerSettings.dashWasmUrl,
logLevel: log.getLevel(),
logFormat: log.getFormat(),
sendBackLogs: isDebugModeEnabled(),
date: Date.now(),
timestamp: getMonotonicTimeStamp(),
hasVideo: ((_a = this.videoElement) === null || _a === void 0 ? void 0 : _a.nodeName.toLowerCase()) === "video",
hasMseInWorker,
},
});
log.addEventListener("onLogLevelChange", (logInfo) => {
if (this._priv_worker === null) {
return;
}
log.debug("---> Sending To Worker:", "log-level-update" /* MainThreadMessageType.LogLevelUpdate */);
this._priv_worker.postMessage({
type: "log-level-update" /* MainThreadMessageType.LogLevelUpdate */,
value: {
logLevel: logInfo.level,
logFormat: logInfo.format,
sendBackLogs: isDebugModeEnabled(),
},
});
}, this._destroyCanceller.signal);
const sendConfigUpdates = (updates) => {
if (this._priv_worker === null) {
return;
}
log.debug("---> Sending To Worker:", "config-update" /* MainThreadMessageType.ConfigUpdate */);
this._priv_worker.postMessage({
type: "config-update" /* 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}
*/
getCurrentModeInformation() {
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(evt, fn) {
// 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() {
if (this._priv_contentInfos !== null) {
this._priv_contentInfos.currentContentCanceller.cancel();
}
this._priv_cleanUpCurrentContentState();
if (this.state !== "STOPPED" /* PLAYER_STATES.STOPPED */) {
this._priv_setPlayerState("STOPPED" /* PLAYER_STATES.STOPPED */);
}
}
/**
* Free the resources used by the player.
* /!\ The player cannot be "used" anymore after this method has been called.
*/
dispose() {
// 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) => {
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) {
const options = parseLoadVideoOptions(opts);
log.info("API: Calling loadvideo", options.url, 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) {
var _a, _b, _c;
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;
if (((_a = reloadOpts === null || reloadOpts === void 0 ? void 0 : reloadOpts.reloadAt) === null || _a === void 0 ? void 0 : _a.position) !== undefined) {
startAt = { position: reloadOpts.reloadAt.position };
}
else if (((_b = reloadOpts === null || reloadOpts === void 0 ? void 0 : reloadOpts.reloadAt) === null || _b === void 0 ? void 0 : _b.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;
if ((reloadOpts === null || reloadOpts === void 0 ? void 0 : reloadOpts.autoPlay) !== undefined) {
autoPlay = reloadOpts.autoPlay;
}
else if (reloadInPause !== undefined) {
autoPlay = !reloadInPause;
}
let keySystems;
if ((reloadOpts === null || reloadOpts === void 0 ? void 0 : reloadOpts.keySystems) !== undefined) {
keySystems = reloadOpts.keySystems;
}
else if (((_c = this._priv_reloadingMetadata.options) === null || _c === void 0 ? void 0 : _c.keySystems) !== undefined) {
keySystems = this._priv_reloadingMetadata.options.keySystems;
}
const newOptions = Object.assign(Object.assign({}, 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);
}
createDebugElement(element) {
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>}
*/
getAvailableThumbnailTracks({ time, periodId, } = {}) {
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");
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}
*/
async renderThumbnail(options) {
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
*/
_priv_initializeContentPlayback(options) {
var _a, _b, _c, _d, _f, _g;
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, } = 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;
let useWorker = false;
let mediaElementTracksStore = null;
if (!isDirectFile) {
/** Interface used to load and refresh the Manifest. */
const manifestRequestSettings = {
lowLatencyMode,
maxRetry: (_a = requestConfig.manifest) === null || _a === void 0 ? void 0 : _a.maxRetry,
requestTimeout: (_b = requestConfig.manifest) === null || _b === void 0 ? void 0 : _b.timeout,
connectionTimeout: (_c = requestConfig.manifest) === null || _c === void 0 ? void 0 : _c.connectionTimeout,
minimumManifestUpdateInterval,
initialManifest,
};
const relyOnVideoVisibilityAndSize = canRelyOnVideoVisibilityAndSize();
const throttlers = {
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" }
: {
textTrackMode: "html",
textTrackElement: options.textTrackElement,
};
const bufferOptions = objectAssign({ enableFastSwitching, onCodecSwitch }, this._priv_bufferOptions);
const segmentRequestOptions = {
lowLatencyMode,
maxRetry: (_d = requestConfig.segment) === null || _d === void 0 ? void 0 : _d.maxRetry,
requestTimeout: (_f = requestConfig.segment) === null || _f === void 0 ? void 0 : _f.timeout,
connectionTimeout: (_g = requestConfig.segment) === null || _g === void 0 ? void 0 : _g.connectionTimeout,
};
const canRunInMultiThread = features.multithread !== null &&
this._priv_worker !== null &&
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,
});
}
}
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,
});
}
/** Future `this._priv_contentInfos` related to this content. */
const contentInfos = {
contentId: generateContentId(),
originalUrl: url,
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,
},
};
// 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();
/** Global "playback observer" which will emit playback conditions */
const playbackObserver = new MediaElementPlaybackObserver(videoElement, {
withMediaSource: !isDirectFile,
lowLatencyMode,
});
currentContentCanceller.signal.register(() => {
playbackObserver.stop();
});
// Update the RxPlayer's state at the right events
const playerStateRef = constructPlayerStateReference(initializer, videoElement, playbackObserver, 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) => {
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 = 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) => {
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 = null;
// React to player state change
playerStateRef.onUpdate((newState) => {
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() {
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() {
// eslint-disable-next-line @typescript-eslint/no-restricted-types
return this.videoElement;
}
/**
* Returns the player's current state.
* @returns {string} - The current Player's state
*/
getPlayerState() {
return this.state;
}
/**
* Returns true if a content is loaded.
* @returns {Boolean} - `true` if a content is loaded, `false` otherwise.
*/
isContentLoaded() {
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() {
return arrayIncludes(["BUFFERING", "SEEKING", "LOADING", "RELOADING"], this.state);
}
/**
* Returns the play/pause status of the player :
* - when `LOADING` or `RELOADING`, returns the scheduled play/pause condition
* for when loading is over,
* - in other states, returns the `<video>` element .paused value,
* - if the player is disposed, returns `true`.
* @returns {Boolean} - `true` if the player is paused or will be after loading,
* `false` otherwise.
*/
isPaused() {
if (this.videoElement !== null) {
if (arrayIncludes(["LOADING", "RELOADING"], this.state)) {
return !this._priv_lastAutoPlay;
}
else {
return this.videoElement.paused;
}
}
return true;
}
/**
* Returns true if both:
* - a content is loaded
* - the content loaded is a live content
* @returns {Boolean} - `true` if we're playing a live content, `false` otherwise.
*/
isLive() {
if (this._priv_contentInfos === null) {
return false;
}
const { isDirectFile, manifest } = this._priv_contentInfos;
if (isDirectFile || manifest === null) {
return false;
}
return manifest.isLive;
}
/**
* Returns `true` if trickmode playback is active (usually through the usage
* of the `setPlaybackRate` method), which means that the RxPlayer selects
* "trickmode" video tracks in priority.
* @returns {Boolean}
*/
areTrickModeTracksEnabled() {
return this._priv_preferTrickModeTracks;
}
/**
* Returns the URL(s) of the currently considered Manifest, or of the content for
* directfile content.
* @returns {Array.<string>|undefined} - Current URL. `undefined` if not known
* or no URL yet.
*/
getContentUrls() {
if (this._priv_contentInfos === null) {
return undefined;
}
const { isDirectFile, manifest, originalUrl } = this._priv_contentInfos;
if (isDirectFile) {
return originalUrl === undefined ? undefined : [originalUrl];
}
if (manifest !== null) {
return manifest.uris;
}
return undefined;
}
/**
* Update URL of the content currently being played (e.g. DASH's MPD).
* @param {Array.<string>|undefined} urls - URLs to reach that content /
* Manifest from the most prioritized URL to the least prioritized URL.
* @param {Object|undefined} [params]
* @param {boolean} params.refresh - If `true` the resource in question
* (e.g. DASH's MPD) will be refreshed immediately.
*/
updateContentUrls(urls, params) {
if (this._priv_contentInfos === null) {
throw new Error("No content loaded");
}
const refreshNow = (params === null || params === void 0 ? void 0 : params.refresh) === true;
this._priv_contentInfos.initializer.updateContentUrls(urls, refreshNow);
}
/**
* Returns the video duration, in seconds.
* NaN if no video is playing.
* @returns {Number}
*/
getMediaDuration() {
if (this.videoElement === null) {
throw new Error("Disposed player");
}
return this.videoElement.duration;
}
/**
* Returns in seconds the difference between:
* - the end of the current contiguous loaded range.
* - the current time
* @returns {Number}
*/
getCurrentBufferGap() {
if (this.videoElement === null) {
throw new Error("Disposed player");
}
const videoElement = this.videoElement;
const bufferGap = getLeftSizeOfBufferedTimeRange(videoElement.buffered, videoElement.currentTime);
if (bufferGap === Infinity) {
return 0;
}
return bufferGap;
}
/**
* Get the current position, in s, in wall-clock time.
* That is:
* - for live content, get a timestamp, in s, of the current played content.
* - for static content, returns the position from beginning in s.
*
* If you do not know if you want to use this method or getPosition:
* - If what you want is to display the current time to the user, use this
* one.
* - If what you want is to interact with the player's API or perform other
* actions (like statistics) with the real player data, use getPosition.
*
* @returns {Number}
*/
getWallClockTime() {
var _a;
if (this.videoElement === null) {
throw new Error("Disposed player");
}
if (this._priv_contentInfos === null) {
return this.videoElement.currentTime;
}
const { isDirectFile, manifest } = this._priv_contentInfos;
if (isDirectFile) {
const startDate = getStartDate(this.videoElement);
return (startDate !== null && startDate !== void 0 ? startDate : 0) + this.videoElement.currentTime;
}
if (manifest !== null) {
const currentTime = this.videoElement.currentTime;
const ast = (_a = manifest.availabilityStartTime) !== null && _a !== void 0 ? _a : 0;
return currentTime + ast;
}
return 0;
}
/**
* Get the current position, in seconds, of the video element.
*
* If you do not know if you want to use this method or getWallClockTime:
* - If what you want is to display the current time to the user, use
* getWallClockTime.
* - If what you want is to interact with the player's API or perform other
* actions (like statistics) with the real player data, use this one.
*
* @returns {Number}
*/
getPosition() {
if (this.videoElement === null) {
throw new Error("Disposed player");
}
return this.videoElement.currentTime;
}
/**
* Returns the last stored content position, in seconds.
*
* @returns {number|undefined}
*/
getLastStoredContentPosition() {
return this._priv_reloadingMetadata.reloadPosition;
}
/**
* Returns the current playback rate at which the video plays.
* @returns {Number}
*/
getPlaybackRate() {
return this._priv_speed.getValue();
}
/**
* Update the playback rate of the video.
*
* This method's effect is persisted from content to content, and can be
* called even when no content is playing (it will still have an effect for
* the next contents).
*
*