rx-player
Version:
Canal+ HTML5 Video Player
1,376 lines (1,300 loc) • 52.9 kB
text/typescript
/**
* 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 type { IMediaElement } from "../../compat/browser_compatibility_types";
import isCodecSupported from "../../compat/is_codec_supported";
import mayMediaElementFailOnUndecipherableData from "../../compat/may_media_element_fail_on_undecipherable_data";
import shouldReloadMediaSourceOnDecipherabilityUpdate from "../../compat/should_reload_media_source_on_decipherability_update";
import config from "../../config";
import type {
IAdaptiveRepresentationSelectorArguments,
IRepresentationEstimator,
} from "../../core/adaptive";
import AdaptiveRepresentationSelector from "../../core/adaptive";
import CmcdDataBuilder from "../../core/cmcd";
import {
CdnPrioritizer,
createThumbnailFetcher,
ManifestFetcher,
SegmentQueueCreator,
} from "../../core/fetchers";
import createContentTimeBoundariesObserver from "../../core/main/common/create_content_time_boundaries_observer";
import type { IFreezeResolution } from "../../core/main/common/FreezeResolver";
import FreezeResolver from "../../core/main/common/FreezeResolver";
import getThumbnailData from "../../core/main/common/get_thumbnail_data";
import synchronizeSegmentSinksOnObservation from "../../core/main/common/synchronize_sinks_on_observation";
import SegmentSinksStore from "../../core/segment_sinks";
import type {
IStreamOrchestratorOptions,
IStreamOrchestratorCallbacks,
INeedsBufferFlushPayload,
} from "../../core/stream";
import StreamOrchestrator from "../../core/stream";
import type { ITextDisplayerInterface } from "../../core/types";
import type { EncryptedMediaError } from "../../errors";
import { MediaError } from "../../errors";
import features from "../../features";
import log from "../../log";
import type { IManifest, IPeriodMetadata, ICodecSupportInfo } from "../../manifest";
import type MainMediaSourceInterface from "../../mse/main_media_source_interface";
import type { IMediaElementPlaybackObserver } from "../../playback_observer";
import type {
ICmcdOptions,
IInitialManifest,
IKeySystemOption,
IPlayerError,
} from "../../public_types";
import type { IThumbnailResponse, ITransportPipelines } from "../../transports";
import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal";
import assert, { assertUnreachable } from "../../utils/assert";
import createCancellablePromise from "../../utils/create_cancellable_promise";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import noop from "../../utils/noop";
import objectAssign from "../../utils/object_assign";
import type { IReadOnlySharedReference } from "../../utils/reference";
import type { ISyncOrAsyncValue } from "../../utils/sync_or_async";
import SyncOrAsync from "../../utils/sync_or_async";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";
import { ContentDecryptorState, getKeySystemConfiguration } from "../decrypt";
import type { IProcessedProtectionData } from "../decrypt";
import type ContentDecryptor from "../decrypt";
import type { ITextDisplayer } from "../text_displayer";
import type { ITextDisplayerOptions } from "./types";
import { ContentInitializer } from "./types";
import createCorePlaybackObserver from "./utils/create_core_playback_observer";
import createMediaSource from "./utils/create_media_source";
import type { IInitialTimeOptions } from "./utils/get_initial_time";
import getInitialTime from "./utils/get_initial_time";
import getLoadedReference from "./utils/get_loaded_reference";
import performInitialSeekAndPlay from "./utils/initial_seek_and_play";
import initializeContentDecryption from "./utils/initialize_content_decryption";
import MainThreadTextDisplayerInterface from "./utils/main_thread_text_displayer_interface";
import RebufferingController from "./utils/rebuffering_controller";
import StreamEventsEmitter from "./utils/stream_events_emitter";
import listenToMediaError from "./utils/throw_on_media_error";
/**
* Allows to load a new content thanks to the MediaSource Extensions (a.k.a. MSE)
* Web APIs.
*
* Through this `ContentInitializer`, a Manifest will be fetched (and depending
* on the situation, refreshed), a `MediaSource` instance will be linked to the
* wanted `HTMLMediaElement` and chunks of media data, called segments, will be
* pushed on buffers associated to this `MediaSource` instance.
*
* @class MediaSourceContentInitializer
*/
export default class MediaSourceContentInitializer extends ContentInitializer {
/** Constructor settings associated to this `MediaSourceContentInitializer`. */
private _initSettings: IInitializeArguments;
/**
* `TaskCanceller` allowing to abort everything that the
* `MediaSourceContentInitializer` is doing.
*/
private _initCanceller: TaskCanceller;
/** Interface allowing to fetch and refresh the Manifest. */
private _manifestFetcher: ManifestFetcher;
/**
* Reference to the `Manifest` Object:
* - as an asynchronous value if it is still in the process of being loaded.
* - as an synchronous value if it has been loaded
* - `null` if the load task has not started yet.
*/
private _manifest: ISyncOrAsyncValue<IManifest> | null;
private _cmcdDataBuilder: CmcdDataBuilder | null;
/**
* Describes the decryption capabilities on the current content, discriminated
* by a `status` property:
*
* - If set to `"uninitialized"`, decryption capabilities have not been
* set up yet.
*
* - If set to `"disabled"`, decryption capabilities are explicitely
* disabled. If encrypted content needs to be decrypted, the accompanying
* error `value` describes the reason why decryption is not enabled.
*
* - If set to `"enabled"`, decryption capabilities are available, and
* `value` points to the corresponding `ContentDecryptor`.
*/
private _decryptionCapabilities:
| {
status: "uninitialized";
value: null;
}
| {
status: "disabled";
value: EncryptedMediaError;
}
| {
status: "enabled";
value: ContentDecryptor;
};
/**
* Create a new `MediaSourceContentInitializer`, associated to the given
* settings.
* @param {Object} settings
*/
constructor(settings: IInitializeArguments) {
super();
this._initSettings = settings;
this._initCanceller = new TaskCanceller();
this._manifest = null;
this._decryptionCapabilities = { status: "uninitialized", value: null };
const urls = settings.url === undefined ? undefined : [settings.url];
this._cmcdDataBuilder =
settings.cmcd === undefined ? null : new CmcdDataBuilder(settings.cmcd);
this._manifestFetcher = new ManifestFetcher(urls, settings.transport, {
...settings.manifestRequestSettings,
lowLatencyMode: settings.lowLatencyMode,
cmcdDataBuilder: this._cmcdDataBuilder,
});
}
/**
* Perform non-destructive preparation steps, to prepare a future content.
* For now, this mainly mean loading the Manifest document.
*/
public prepare(): void {
if (this._manifest !== null) {
return;
}
this._manifest = SyncOrAsync.createAsync(
createCancellablePromise(this._initCanceller.signal, (res, rej) => {
this._manifestFetcher.addEventListener("warning", (err: IPlayerError) =>
this.trigger("warning", err),
);
this._manifestFetcher.addEventListener("error", (err: unknown) => {
this.trigger("error", err);
rej(err);
});
this._manifestFetcher.addEventListener("manifestReady", (manifest) => {
res(manifest);
});
}),
);
this._manifestFetcher.start();
this._initCanceller.signal.register(() => {
this._manifestFetcher.dispose();
});
}
/**
* @param {HTMLMediaElement} mediaElement
* @param {Object} playbackObserver
*/
public start(
mediaElement: IMediaElement,
playbackObserver: IMediaElementPlaybackObserver,
): void {
this.prepare(); // Load Manifest if not already done
/** Translate errors coming from the media element into RxPlayer errors. */
listenToMediaError(
mediaElement,
(error: MediaError) => this._onFatalError(error),
this._initCanceller.signal,
);
this._setupInitialMediaSourceAndDecryption(mediaElement)
.then((initResult) =>
this._onInitialMediaSourceReady(
mediaElement,
initResult.mediaSource,
playbackObserver,
initResult.drmSystemId,
initResult.unlinkMediaSource,
),
)
.catch((err) => {
this._onFatalError(err);
});
}
/**
* Update URL of the Manifest.
* @param {Array.<string>|undefined} urls - URLs to reach that Manifest from
* the most prioritized URL to the least prioritized URL.
* @param {boolean} refreshNow - If `true` the resource in question (e.g.
* DASH's MPD) will be refreshed immediately.
*/
public updateContentUrls(urls: string[] | undefined, refreshNow: boolean): void {
this._manifestFetcher.updateContentUrls(urls, refreshNow);
}
/**
* Stop content and free all resources linked to this
* `MediaSourceContentInitializer`.
*/
public dispose(): void {
this._initCanceller.cancel();
}
/**
* Callback called when an error interrupting playback arised.
* @param {*} err
*/
private _onFatalError(err: unknown) {
if (this._initCanceller.isUsed()) {
return;
}
this._initCanceller.cancel();
this.trigger("error", err);
}
/**
* Initialize decryption mechanisms if needed and begin creating and relying
* on the initial `MediaSourceInterface` for this content.
* @param {HTMLMediaElement|null} mediaElement
* @returns {Promise.<Object>}
*/
private _setupInitialMediaSourceAndDecryption(mediaElement: IMediaElement): Promise<{
mediaSource: MainMediaSourceInterface;
drmSystemId: string | undefined;
unlinkMediaSource: TaskCanceller;
}> {
const initCanceller = this._initCanceller;
return createCancellablePromise(initCanceller.signal, (resolve) => {
const { keySystems } = this._initSettings;
/** Initialize decryption capabilities. */
const { statusRef: drmInitRef, contentDecryptor } = initializeContentDecryption(
mediaElement,
keySystems,
{
onWarning: (err: IPlayerError) => this.trigger("warning", err),
onError: (err: Error) => this._onFatalError(err),
onBlackListProtectionData: (val) => {
// Ugly IIFE workaround to allow async event listener
(async () => {
if (this._manifest === null) {
return;
}
const manifest =
this._manifest.syncValue ?? (await this._manifest.getValueAsAsync());
blackListProtectionDataOnManifest(manifest, val);
})().catch(noop);
},
onKeyIdsCompatibilityUpdate: (updates) => {
// Ugly IIFE workaround to allow async event listener
(async () => {
if (this._manifest === null) {
return;
}
const manifest =
this._manifest.syncValue ?? (await this._manifest.getValueAsAsync());
updateKeyIdsDecipherabilityOnManifest(
manifest,
updates.whitelistedKeyIds,
updates.blacklistedKeyIds,
updates.delistedKeyIds,
);
})().catch(noop);
},
onCodecSupportUpdate: () => {
const syncManifest = this._manifest?.syncValue;
if (isNullOrUndefined(syncManifest)) {
// The Manifest is not yet fetched, but we will be able to check
// the codecs once it is the case
this._manifest?.getValueAsAsync().then((loadedManifest) => {
if (this._initCanceller.isUsed()) {
return;
}
this._refreshManifestCodecSupport(loadedManifest);
}, noop);
} else {
this._refreshManifestCodecSupport(syncManifest);
}
},
},
initCanceller.signal,
);
if (contentDecryptor.enabled) {
this._decryptionCapabilities = {
status: "enabled",
value: contentDecryptor.value,
};
} else {
this._decryptionCapabilities = {
status: "disabled",
value: contentDecryptor.value,
};
}
drmInitRef.onUpdate(
(drmStatus, stopListeningToDrmUpdates) => {
if (drmStatus.initializationState.type === "uninitialized") {
return;
}
stopListeningToDrmUpdates();
const mediaSourceCanceller = new TaskCanceller();
mediaSourceCanceller.linkToSignal(initCanceller.signal);
createMediaSource(mediaElement, mediaSourceCanceller.signal)
.then((mediaSource) => {
const lastDrmStatus = drmInitRef.getValue();
if (lastDrmStatus.initializationState.type === "awaiting-media-link") {
lastDrmStatus.initializationState.value.isMediaLinked.setValue(true);
drmInitRef.onUpdate(
(newDrmStatus, stopListeningToDrmUpdatesAgain) => {
if (newDrmStatus.initializationState.type === "initialized") {
stopListeningToDrmUpdatesAgain();
resolve({
mediaSource,
drmSystemId: newDrmStatus.drmSystemId,
unlinkMediaSource: mediaSourceCanceller,
});
return;
}
},
{ emitCurrentValue: true, clearSignal: initCanceller.signal },
);
} else if (drmStatus.initializationState.type === "initialized") {
resolve({
mediaSource,
drmSystemId: drmStatus.drmSystemId,
unlinkMediaSource: mediaSourceCanceller,
});
return;
}
})
.catch((err) => {
if (mediaSourceCanceller.isUsed()) {
return;
}
this._onFatalError(err);
});
},
{ emitCurrentValue: true, clearSignal: initCanceller.signal },
);
});
}
private async _onInitialMediaSourceReady(
mediaElement: IMediaElement,
initialMediaSource: MainMediaSourceInterface,
playbackObserver: IMediaElementPlaybackObserver,
drmSystemId: string | undefined,
initialMediaSourceCanceller: TaskCanceller,
): Promise<void> {
const {
adaptiveOptions,
autoPlay,
bufferOptions,
lowLatencyMode,
segmentRequestOptions,
speed,
startAt,
textTrackOptions,
transport,
} = this._initSettings;
const initCanceller = this._initCanceller;
assert(this._manifest !== null);
let manifest: IManifest;
try {
manifest = this._manifest.syncValue ?? (await this._manifest.getValueAsAsync());
} catch (_e) {
return; // The error should already have been processed through an event listener
}
manifest.addEventListener(
"manifestUpdate",
(updates) => {
this.trigger("manifestUpdate", updates);
this._refreshManifestCodecSupport(manifest);
},
initCanceller.signal,
);
manifest.addEventListener(
"decipherabilityUpdate",
(elts) => {
this.trigger("decipherabilityUpdate", elts);
},
initCanceller.signal,
);
manifest.addEventListener(
"supportUpdate",
() => {
this.trigger("codecSupportUpdate", null);
},
initCanceller.signal,
);
log.debug("Init: Calculating initial time");
const initialTime = getInitialTime(manifest, lowLatencyMode, startAt);
log.debug("Init: Initial time calculated:", initialTime);
/** Choose the right "Representation" for a given "Adaptation". */
const representationEstimator = AdaptiveRepresentationSelector(adaptiveOptions);
const subBufferOptions = objectAssign(
{ textTrackOptions, drmSystemId },
bufferOptions,
);
const cdnPrioritizer = new CdnPrioritizer(initCanceller.signal);
const segmentQueueCreator = new SegmentQueueCreator(
transport,
cdnPrioritizer,
this._cmcdDataBuilder,
segmentRequestOptions,
);
this._refreshManifestCodecSupport(manifest);
this.trigger("manifestReady", manifest);
if (initCanceller.isUsed()) {
return;
}
// handle initial load and reloads
this._setupContentWithNewMediaSource(
{
mediaElement,
playbackObserver,
mediaSource: initialMediaSource,
initialTime,
autoPlay,
manifest,
representationEstimator,
cdnPrioritizer,
segmentQueueCreator,
speed,
bufferOptions: subBufferOptions,
},
initialMediaSourceCanceller,
);
}
/**
* Load the content defined by the Manifest in the mediaSource given at the
* given position and playing status.
* This function recursively re-call itself when a MediaSource reload is
* wanted.
* @param {Object} args
* @param {Object} currentCanceller
*/
private _setupContentWithNewMediaSource(
args: IBufferingMediaSettings,
currentCanceller: TaskCanceller,
): void {
this._startLoadingContentOnMediaSource(
args,
this._createReloadMediaSourceCallback(args, currentCanceller),
currentCanceller.signal,
);
}
/**
* Create `IReloadMediaSourceCallback` allowing to handle reload orders.
* @param {Object} args
* @param {Object} currentCanceller
*/
private _createReloadMediaSourceCallback(
args: IBufferingMediaSettings,
currentCanceller: TaskCanceller,
): IReloadMediaSourceCallback {
const initCanceller = this._initCanceller;
return (reloadOrder: { position: number; autoPlay: boolean }): void => {
currentCanceller.cancel();
if (initCanceller.isUsed()) {
return;
}
this.trigger("reloadingMediaSource", reloadOrder);
if (initCanceller.isUsed()) {
return;
}
const newCanceller = new TaskCanceller();
newCanceller.linkToSignal(initCanceller.signal);
createMediaSource(args.mediaElement, newCanceller.signal)
.then((newMediaSource) => {
this._setupContentWithNewMediaSource(
{
...args,
mediaSource: newMediaSource,
initialTime: reloadOrder.position,
autoPlay: reloadOrder.autoPlay,
},
newCanceller,
);
})
.catch((err) => {
if (newCanceller.isUsed()) {
return;
}
this._onFatalError(err);
});
};
}
/**
* Buffer the content on the given MediaSource.
* @param {Object} args
* @param {function} onReloadOrder
* @param {Object} cancelSignal
*/
private _startLoadingContentOnMediaSource(
args: IBufferingMediaSettings,
onReloadOrder: IReloadMediaSourceCallback,
cancelSignal: CancellationSignal,
): void {
const {
autoPlay,
bufferOptions,
initialTime,
manifest,
mediaElement,
mediaSource,
playbackObserver,
representationEstimator,
cdnPrioritizer,
segmentQueueCreator,
speed,
} = args;
const { transport } = this._initSettings;
const initialPeriod =
manifest.getPeriodForTime(initialTime) ?? manifest.getNextPeriod(initialTime);
if (initialPeriod === undefined) {
const error = new MediaError(
"MEDIA_STARTING_TIME_NOT_FOUND",
"Wanted starting time not found in the Manifest.",
);
return this._onFatalError(error);
}
let textDisplayerInterface: ITextDisplayerInterface | null = null;
const textDisplayer = createTextDisplayer(
mediaElement,
this._initSettings.textTrackOptions,
);
if (textDisplayer !== null) {
const sender = new MainThreadTextDisplayerInterface(textDisplayer);
textDisplayerInterface = sender;
cancelSignal.register(() => {
sender.stop();
textDisplayer?.stop();
});
}
/** Interface to create media buffers. */
const segmentSinksStore = new SegmentSinksStore(
mediaSource,
mediaElement.nodeName === "VIDEO",
textDisplayerInterface,
);
cancelSignal.register(() => {
segmentSinksStore.disposeAll();
});
const { autoPlayResult, initialPlayPerformed } = performInitialSeekAndPlay(
{
mediaElement,
playbackObserver,
startTime: initialTime,
mustAutoPlay: autoPlay,
onWarning: (err) => {
this.trigger("warning", err);
},
isDirectfile: false,
},
cancelSignal,
);
if (cancelSignal.isCancelled()) {
return;
}
initialPlayPerformed.onUpdate(
(isPerformed, stopListening) => {
if (isPerformed) {
stopListening();
const streamEventsEmitter = new StreamEventsEmitter(manifest, playbackObserver);
manifest.addEventListener(
"manifestUpdate",
() => {
streamEventsEmitter.onManifestUpdate(manifest);
},
cancelSignal,
);
streamEventsEmitter.addEventListener(
"event",
(payload) => {
this.trigger("streamEvent", payload);
},
cancelSignal,
);
streamEventsEmitter.addEventListener(
"eventSkip",
(payload) => {
this.trigger("streamEventSkip", payload);
},
cancelSignal,
);
streamEventsEmitter.start();
cancelSignal.register(() => {
streamEventsEmitter.stop();
});
}
},
{ clearSignal: cancelSignal, emitCurrentValue: true },
);
const coreObserver = createCorePlaybackObserver(
playbackObserver,
{
autoPlay,
manifest,
mediaSource,
textDisplayer,
initialPlayPerformed,
speed,
},
cancelSignal,
);
this._cmcdDataBuilder?.startMonitoringPlayback(coreObserver);
cancelSignal.register(() => {
this._cmcdDataBuilder?.stopMonitoringPlayback();
});
const rebufferingController = this._createRebufferingController(
playbackObserver,
manifest,
speed,
cancelSignal,
);
const freezeResolver = new FreezeResolver(segmentSinksStore);
if (mayMediaElementFailOnUndecipherableData) {
// On some devices, just reload immediately when data become undecipherable
manifest.addEventListener(
"decipherabilityUpdate",
(elts) => {
if (elts.some((e) => e.representation.decipherable !== true)) {
reloadMediaSource(0, undefined, undefined);
}
},
cancelSignal,
);
}
coreObserver.listen(
(observation) => {
synchronizeSegmentSinksOnObservation(observation, segmentSinksStore);
const freezeResolution = freezeResolver.onNewObservation(observation);
if (freezeResolution === null) {
return;
}
// TODO: The following method looks generic, we may be able to factorize
// it with other reload handlers after some work.
const triggerReload = () => {
const lastObservation = playbackObserver.getReference().getValue();
const position = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: (coreObserver.getCurrentTime() ?? lastObservation.position.getPolled());
const autoplay = initialPlayPerformed.getValue()
? !playbackObserver.getIsPaused()
: autoPlay;
onReloadOrder({ position, autoPlay: autoplay });
};
handleFreezeResolution(freezeResolution, {
enableRepresentationAvoidance: this._initSettings.enableRepresentationAvoidance,
manifest,
triggerReload,
playbackObserver,
});
},
{ clearSignal: cancelSignal },
);
const contentTimeBoundariesObserver = createContentTimeBoundariesObserver(
manifest,
mediaSource,
coreObserver,
segmentSinksStore,
{
onWarning: (err: IPlayerError) => this.trigger("warning", err),
onPeriodChanged: (period: IPeriodMetadata) =>
this.trigger("activePeriodChanged", { period }),
},
cancelSignal,
);
/**
* Emit a "loaded" events once the initial play has been performed and the
* media can begin playback.
* Also emits warning events if issues arise when doing so.
*/
autoPlayResult
.then(() => {
getLoadedReference(playbackObserver, false, cancelSignal).onUpdate(
(isLoaded, stopListening) => {
if (isLoaded) {
stopListening();
this.trigger("loaded", {
getSegmentSinkMetrics: async () => {
return new Promise((resolve) =>
resolve(segmentSinksStore.getSegmentSinksMetrics()),
);
},
getThumbnailData: async (
periodId: string,
thumbnailTrackId: string,
time: number,
): Promise<IThumbnailResponse> => {
const fetchThumbnails = createThumbnailFetcher(
transport.thumbnails,
cdnPrioritizer,
);
return getThumbnailData(
fetchThumbnails,
manifest,
periodId,
thumbnailTrackId,
time,
);
},
});
}
},
{ emitCurrentValue: true, clearSignal: cancelSignal },
);
})
.catch((err) => {
if (cancelSignal.isCancelled()) {
return; // Current loading cancelled, no need to trigger the error
}
this._onFatalError(err);
});
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
StreamOrchestrator(
{ manifest, initialPeriod },
coreObserver,
representationEstimator,
segmentSinksStore,
segmentQueueCreator,
bufferOptions,
handleStreamOrchestratorCallbacks(),
cancelSignal,
);
/**
* Returns Object handling the callbacks from a `StreamOrchestrator`, which
* are basically how it communicates about events.
* @returns {Object}
*/
function handleStreamOrchestratorCallbacks(): IStreamOrchestratorCallbacks {
return {
needsBufferFlush: (payload?: INeedsBufferFlushPayload) => {
let wantedSeekingTime: number;
const lastObservation = playbackObserver.getReference().getValue();
const currentTime = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: mediaElement.currentTime;
const relativeResumingPosition = payload?.relativeResumingPosition ?? 0;
const canBeApproximateSeek = Boolean(payload?.relativePosHasBeenDefaulted);
if (relativeResumingPosition === 0 && canBeApproximateSeek) {
// in case relativeResumingPosition is 0, we still perform
// a tiny seek to be sure that the browser will correclty reload the video.
wantedSeekingTime = currentTime + 0.001;
} else {
wantedSeekingTime = currentTime + relativeResumingPosition;
}
playbackObserver.setCurrentTime(wantedSeekingTime);
// Seek again once data begins to be buffered.
// This is sadly necessary on some browsers to avoid decoding
// issues after a flush.
//
// NOTE: there's in theory a potential race condition in the following
// logic as the callback could be called when media data is still
// being removed by the browser - which is an asynchronous process.
// The following condition checking for buffered data could thus lead
// to a false positive where we're actually checking previous data.
// For now, such scenario is avoided by setting the
// `includeLastObservation` option to `false` and calling
// `needsBufferFlush` once MSE media removal operations have been
// explicitely validated by the browser, but that's a complex and easy
// to break system.
playbackObserver.listen(
(obs, stopListening) => {
if (
// Data is buffered around the current position
obs.currentRange !== null ||
// Or, for whatever reason, we have no buffer but we're already advancing
obs.position.getPolled() > wantedSeekingTime + 0.1
) {
stopListening();
playbackObserver.setCurrentTime(obs.position.getWanted() + 0.001);
}
},
{ includeLastObservation: false, clearSignal: cancelSignal },
);
},
streamStatusUpdate(value) {
// Announce discontinuities if found
const { period, bufferType, imminentDiscontinuity, position } = value;
rebufferingController.updateDiscontinuityInfo({
period,
bufferType,
discontinuity: imminentDiscontinuity,
position,
});
if (cancelSignal.isCancelled()) {
return; // Previous call has stopped streams due to a side-effect
}
// If the status for the last Period indicates that segments are all loaded
// or on the contrary that the loading resumed, announce it to the
// ContentTimeBoundariesObserver.
if (
manifest.isLastPeriodKnown &&
value.period.id === manifest.periods[manifest.periods.length - 1].id
) {
const hasFinishedLoadingLastPeriod =
value.hasFinishedLoading || value.isEmptyStream;
if (hasFinishedLoadingLastPeriod) {
contentTimeBoundariesObserver.onLastSegmentFinishedLoading(
value.bufferType,
);
} else {
contentTimeBoundariesObserver.onLastSegmentLoadingResume(value.bufferType);
}
}
},
needsManifestRefresh: () =>
self._manifestFetcher.scheduleManualRefresh({
enablePartialRefresh: true,
canUseUnsafeMode: true,
}),
manifestMightBeOufOfSync: () => {
const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent();
self._manifestFetcher.scheduleManualRefresh({
enablePartialRefresh: false,
canUseUnsafeMode: false,
delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY,
});
},
lockedStream: (value) =>
rebufferingController.onLockedStream(value.bufferType, value.period),
adaptationChange: (value) => {
self.trigger("adaptationChange", value);
if (cancelSignal.isCancelled()) {
return; // Previous call has stopped streams due to a side-effect
}
contentTimeBoundariesObserver.onAdaptationChange(
value.type,
value.period,
value.adaptation,
);
},
representationChange: (value) => {
self.trigger("representationChange", value);
if (cancelSignal.isCancelled()) {
return; // Previous call has stopped streams due to a side-effect
}
contentTimeBoundariesObserver.onRepresentationChange(value.type, value.period);
},
inbandEvent: (value) => self.trigger("inbandEvents", value),
warning: (value) => self.trigger("warning", value),
periodStreamReady: (value) => self.trigger("periodStreamReady", value),
periodStreamCleared: (value) => {
contentTimeBoundariesObserver.onPeriodCleared(value.type, value.period);
if (cancelSignal.isCancelled()) {
return; // Previous call has stopped streams due to a side-effect
}
self.trigger("periodStreamCleared", {
type: value.type,
periodId: value.period.id,
});
},
bitrateEstimateChange: (value) => {
self._cmcdDataBuilder?.updateThroughput(value.type, value.bitrate);
self.trigger("bitrateEstimateChange", value);
},
needsMediaSourceReload: (payload) => {
reloadMediaSource(
payload.timeOffset,
payload.minimumPosition,
payload.maximumPosition,
);
},
needsDecipherabilityFlush() {
const keySystem = getKeySystemConfiguration(mediaElement);
if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem?.[0])) {
const lastObservation = coreObserver.getReference().getValue();
const position = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: (coreObserver.getCurrentTime() ?? lastObservation.position.getPolled());
const isPaused =
lastObservation.paused.pending ??
coreObserver.getIsPaused() ??
lastObservation.paused.last;
onReloadOrder({ position, autoPlay: !isPaused });
} else {
const lastObservation = coreObserver.getReference().getValue();
const position = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: (coreObserver.getCurrentTime() ?? lastObservation.position.getPolled());
// simple seek close to the current position
// to flush the buffers
if (position + 0.001 < lastObservation.duration) {
playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001);
} else {
playbackObserver.setCurrentTime(position);
}
}
},
encryptionDataEncountered: (value) => {
if (self._decryptionCapabilities.status === "disabled") {
self._onFatalError(self._decryptionCapabilities.value);
return;
} else if (self._decryptionCapabilities.status === "uninitialized") {
// Should never happen
log.error(
"Init: received encryption data without known decryption capabilities",
);
return;
}
for (const protectionData of value) {
self._decryptionCapabilities.value.onInitializationData(protectionData);
if (cancelSignal.isCancelled()) {
return; // Previous call has stopped streams due to a side-effect
}
}
},
error: (err) => self._onFatalError(err),
};
}
/**
* Callback allowing to reload the current content.
* @param {number} deltaPosition - Position you want to seek to after
* reloading, as a delta in seconds from the last polled playing position.
* @param {number|undefined} minimumPosition - If set, minimum time bound
* in seconds after `deltaPosition` has been applied.
* @param {number|undefined} maximumPosition - If set, minimum time bound
* in seconds after `deltaPosition` has been applied.
*/
function reloadMediaSource(
deltaPosition: number,
minimumPosition: number | undefined,
maximumPosition: number | undefined,
): void {
const lastObservation = coreObserver.getReference().getValue();
const currentPosition = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: (coreObserver.getCurrentTime() ?? lastObservation.position.getPolled());
const isPaused =
lastObservation.paused.pending ??
coreObserver.getIsPaused() ??
lastObservation.paused.last;
let position = currentPosition + deltaPosition;
if (minimumPosition !== undefined) {
position = Math.max(minimumPosition, position);
}
if (maximumPosition !== undefined) {
position = Math.min(maximumPosition, position);
}
onReloadOrder({ position, autoPlay: !isPaused });
}
}
/**
* Creates a `RebufferingController`, a class trying to avoid various stalling
* situations (such as rebuffering periods), and returns it.
*
* Various methods from that class need then to be called at various events
* (see `RebufferingController` definition).
*
* This function also handles the `RebufferingController`'s events:
* - emit "stalled" events when stalling situations cannot be prevented,
* - emit "unstalled" events when we could get out of one,
* - emit "warning" on various rebuffering-related minor issues
* like discontinuity skipping.
* @param {Object} playbackObserver
* @param {Object} manifest
* @param {Object} speed
* @param {Object} cancelSignal
* @returns {Object}
*/
private _createRebufferingController(
playbackObserver: IMediaElementPlaybackObserver,
manifest: IManifest,
speed: IReadOnlySharedReference<number>,
cancelSignal: CancellationSignal,
): RebufferingController {
const rebufferingController = new RebufferingController(
playbackObserver,
manifest,
speed,
);
// Bubble-up events
rebufferingController.addEventListener("stalled", (evt) =>
this.trigger("stalled", evt),
);
rebufferingController.addEventListener("unstalled", () =>
this.trigger("unstalled", null),
);
rebufferingController.addEventListener("warning", (err) =>
this.trigger("warning", err),
);
cancelSignal.register(() => rebufferingController.destroy());
rebufferingController.start();
return rebufferingController;
}
/**
* Evaluates a list of codecs to determine their support status.
*
* @param {Array} codecsToCheck - The list of codecs to check.
* @returns {Array} - The list of evaluated codecs with their support status updated.
*/
private getCodecsSupportInfo(
codecsToCheck: Array<{ mimeType: string; codec: string }>,
): ICodecSupportInfo[] {
const codecsSupportInfo: ICodecSupportInfo[] = codecsToCheck.map((codecToCheck) => {
const inputCodec = `${codecToCheck.mimeType};codecs="${codecToCheck.codec}"`;
const isSupported = isCodecSupported(inputCodec);
if (!isSupported) {
return {
mimeType: codecToCheck.mimeType,
codec: codecToCheck.codec,
supported: false,
supportedIfEncrypted: false,
};
}
/**
* `true` if the codec is supported when encrypted, `false` if it is not
* supported, or `undefined` if we cannot obtain that information.
*/
let supportedIfEncrypted: boolean | undefined;
if (this._decryptionCapabilities.status === "uninitialized") {
supportedIfEncrypted = undefined;
} else if (this._decryptionCapabilities.status === "disabled") {
// It's ambiguous here, but let's say that no ContentDecryptor means that
// the codec is supported by it.
supportedIfEncrypted = true;
} else {
const contentDecryptor = this._decryptionCapabilities.value;
if (contentDecryptor.getState() !== ContentDecryptorState.Initializing) {
// No information is available regarding the support status.
// Defaulting to assume the codec is supported.
supportedIfEncrypted =
contentDecryptor.isCodecSupported(
codecToCheck.mimeType,
codecToCheck.codec,
) ?? true;
}
}
return {
mimeType: codecToCheck.mimeType,
codec: codecToCheck.codec,
supported: isSupported,
supportedIfEncrypted,
};
});
return codecsSupportInfo;
}
/**
* Update the support status of all Representations in the Manifest.
*
* To call anytime either the Manifest is linked to new codecs or new means
* to test for codec support are available.
* @param {Object} manifest
*/
private _refreshManifestCodecSupport(manifest: IManifest): void {
const codecsToTest = manifest.getCodecsWithUnknownSupport();
const codecsSupportInfo = this.getCodecsSupportInfo(codecsToTest);
if (codecsSupportInfo.length > 0) {
try {
manifest.updateCodecSupport(codecsSupportInfo);
} catch (err) {
this._onFatalError(err);
}
}
}
}
function createTextDisplayer(
mediaElement: IMediaElement,
textTrackOptions: ITextDisplayerOptions,
): ITextDisplayer | null {
if (textTrackOptions.textTrackMode === "html" && features.htmlTextDisplayer !== null) {
return new features.htmlTextDisplayer(
mediaElement,
textTrackOptions.textTrackElement,
);
} else if (features.nativeTextDisplayer !== null) {
return new features.nativeTextDisplayer(mediaElement);
}
return null;
}
/** Arguments to give to the `InitializeOnMediaSource` function. */
export interface IInitializeArguments {
/** Options concerning the ABR logic. */
adaptiveOptions: IAdaptiveRepresentationSelectorArguments;
/** `true` if we should play when loaded. */
autoPlay: boolean;
/** Options concerning the media buffers. */
bufferOptions: {
/** Buffer "goal" at which we stop downloading new segments. */
wantedBufferAhead: IReadOnlySharedReference<number>;
/** Buffer maximum size in kiloBytes at which we stop downloading */
maxVideoBufferSize: IReadOnlySharedReference<number>;
/** Max buffer size after the current position, in seconds (we GC further up). */
maxBufferAhead: IReadOnlySharedReference<number>;
/** Max buffer size before the current position, in seconds (we GC further down). */
maxBufferBehind: IReadOnlySharedReference<number>;
/**
* Enable/Disable fastSwitching: allow to replace lower-quality segments by
* higher-quality ones to have a faster transition.
*/
enableFastSwitching: boolean;
/** Behavior when a new video and/or audio codec is encountered. */
onCodecSwitch: "continue" | "reload";
};
/**
* When set to an object, enable "Common Media Client Data", or "CMCD".
*/
cmcd?: ICmcdOptions | undefined;
/**
* If `true`, the RxPlayer can enable its "Representation avoidance"
* mechanism, where it avoid loading Representation that it suspect
* have issues being decoded on the current device.
*/
enableRepresentationAvoidance: boolean;
/** Every encryption configuration set. */
keySystems: IKeySystemOption[];
/** `true` to play low-latency contents optimally. */
lowLatencyMode: boolean;
/** Settings linked to Manifest requests. */
manifestRequestSettings: {
/** Maximum number of time a request on error will be retried. */
maxRetry: number | undefined;
/**
* Timeout after which request are aborted and, depending on other options,
* retried.
* To set to `-1` for no timeout.
* `undefined` will lead to a default, large, timeout being used.
*/
requestTimeout: number | undefined;
/**
* Connection timeout, in milliseconds, after which the request is canceled
* if the responses headers has not being received.
* Do not set or set to "undefined" to disable it.
*/
connectionTimeout: number | undefined;
/** Limit the frequency of Manifest updates. */
minimumManifestUpdateInterval: number;
/**
* Potential first Manifest to rely on, allowing to skip the initial Manifest
* request.
*/
initialManifest: IInitialManifest | undefined;
};
/** Logic linked Manifest and segment loading and parsing. */
transport: ITransportPipelines;
/** Configuration for the segment requesting logic. */
segmentRequestOptions: {
lowLatencyMode: boolean;
/**
* Amount of time after which a request should be aborted.
* `undefined` indicates that a default value is wanted.
* `-1` indicates no timeout.
*/
requestTimeout: number | undefined;
/**
* Amount of time, in milliseconds, after which a request that hasn't receive
* the headers and status code should be aborted and optionnaly retried,
* depending on the maxRetry configuration.
*/
connectionTimeout: number | undefined;
/** Maximum number of time a request on error will be retried. */
maxRetry: number | undefined;
};
/** Emit the playback rate (speed) set by the user. */
speed: IReadOnlySharedReference<number>;
/** The configured starting position. */
startAt?: IInitialTimeOptions | undefined;
/** Configuration specific to the text track. */
textTrackOptions: ITextDisplayerOptions;
/** URL of the Manifest. `undefined` if unknown or not pertinent. */
url: string | undefined;
}
/** Arguments needed when starting to buffer media on a specific MediaSource. */
interface IBufferingMediaSettings {
/** Various stream-related options. */
bufferOptions: IStreamOrchestratorOptions;
/* Manifest of the content we want to play. */
manifest: IManifest;
/** Media Element on which the content will be played. */
mediaElement: IMediaElement;
/** Emit playback conditions regularly. */
playbackObserver: IMediaElementPlaybackObserver;
/** Estimate the right Representation. */
representationEstimator: IRepresentationEstimator;
/**
* Interface allowing to prioritize CDN between one another depending on past
* performances, content steering, etc.
*/
cdnPrioritizer: CdnPrioritizer;
/** Module to facilitate segment fetching. */
segmentQueueCreator: SegmentQueueCreator;
/** Last wanted playback rate. */
speed: IReadOnlySharedReference<number>;
/** `MediaSource` element on which the media will be buffered. */
mediaSource: MainMediaSourceInterface;
/** The initial position to seek to in media time, in seconds. */
initialTime: number;
/** If `true` it should automatically play once enough data is loaded. */
autoPlay: boolean;
}
/**
* Change the decipherability of Representations which have their key id in one
* of the given Arrays:
*
* - Those who have a key id listed in `whitelistedKeyIds` will have their
* decipherability updated to `true`
*
* - Those who have a key id listed in `blacklistedKeyIds` will have their
* decipherability updated to `false`
*
* - Those who have a key id listed in `delistedKeyIds` will have their
* decipherability updated to `undefined`.
*
* @param {Object} manifest
* @param {Array.<Uint8Array>} whitelistedKeyIds
* @param {Array.<Uint8Array>} blacklistedKeyIds
* @param {Array.<Uint8Array>} delistedKeyIds
*/
function updateKeyIdsDecipherabilityOnManifest(
manifest: IManifest,
whitelistedKeyIds: Uint8Array[],
blacklistedKeyIds: Uint8Array[],
delistedKeyIds: Uint8Array[],
): void {
manifest.updateRepresentationsDeciperability((ctx) => {
const { representation } = ctx;
if (representation.contentProtections === undefined) {
return representation.decipherable;
}
const contentKIDs = representation.contentProtections.keyIds;
if (contentKIDs !== undefined) {
for (const elt of contentKIDs) {
for (const blacklistedKeyId of blacklistedKeyIds) {
if (areArraysOfNumbersEqual(blacklistedKeyId, elt)) {
return false;
}
}
for (const whitelistedKeyId of whitelistedKeyIds) {
if (areArraysOfNumbersEqual(whitelistedKeyId, elt)) {
return true;
}
}
for (const delistedKeyId of delistedKeyIds) {
if (areArraysOfNumbersEqual(delistedKeyId, elt)) {
return undefined;
}
}
}
}
return representation.decipherable;
});
}
/**
* Update decipherability to `false` to any Representation which is linked to
* the given initialization data.
* @param {Object} manifest
* @param {Object} initData
*/
function blackListProtectionDataOnManifest(
manifest: IManifest,
initData: IProcessedProtectionData,
) {
manifest.updateRepresentationsDeciperability((ctx) => {
const rep = ctx.representation;
if (rep.decipherable === false) {
return false;
}
const segmentProtections = rep.contentProtections?.initData ?? [];
for (const protection of segmentProtections) {
if (initData.type === undefined || protection.type === initData.type) {
const containedInitData = initData.values
.getFormattedValues()
.every((undecipherableVal) => {
return protection.values.some((currVal) => {
return (
(undecipherableVal.systemId === undefined ||
currVal.systemId === undecipherableVal.systemId) &&
areArraysOfNumbersEqu