rx-player
Version:
Canal+ HTML5 Video Player
2,390 lines • 84.7 kB
text/typescript
import type { IMediaElement } from "../../compat/browser_compatibility_types";
import disableRemotePlaybackOnManagedMediaSource from "../../compat/disable_remote_playback_on_managed_media_source";
import getEmeApiImplementation from "../../compat/eme";
import mayMediaElementFailOnUndecipherableData from "../../compat/may_media_element_fail_on_undecipherable_data";
import resetMediaElement from "../../compat/reset_media_element";
import shouldReloadMediaSourceOnDecipherabilityUpdate from "../../compat/should_reload_media_source_on_decipherability_update";
import type { ISegmentSinkMetrics } from "../../core/segment_sinks/segment_sinks_store";
import type {
IAdaptiveRepresentationSelectorArguments,
IAdaptationChoice,
IResolutionInfo,
ICreateMediaSourceCoreMessage,
ISentError,
ICoreMessage,
ISentLogValue,
} from "../../core/types";
import { CoreMessageType } from "../../core/types";
import type { MediaError } from "../../errors";
import {
deserializeMediaError,
EncryptedMediaError,
NetworkError,
OtherError,
SourceBufferError,
} from "../../errors";
import features from "../../features";
import log from "../../log";
import type { IManifestMetadata } from "../../manifest";
import {
ManifestMetadataFormat,
replicateUpdatesOnManifestMetadata,
updateDecipherabilityFromKeyIds,
updateDecipherabilityFromProtectionData,
} from "../../manifest";
import MainMediaSourceInterface from "../../mse/main_media_source_interface";
import type {
IReadOnlyPlaybackObserver,
IMediaElementPlaybackObserver,
} from "../../playback_observer";
import type {
ICmcdOptions,
IInitialManifest,
IKeySystemOption,
IPlayerError,
IRepresentationFilter,
IManifestLoader,
ISegmentLoader,
} from "../../public_types";
import type { IThumbnailResponse, ITransportOptions } from "../../transports";
import arrayFind from "../../utils/array_find";
import assert, { assertUnreachable } from "../../utils/assert";
import idGenerator from "../../utils/id_generator";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import type { IAcceptedLogValue } from "../../utils/logger";
import objectAssign from "../../utils/object_assign";
import type { IReadOnlySharedReference } from "../../utils/reference";
import SharedReference from "../../utils/reference";
import { RequestError } from "../../utils/request";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller, { CancellationError } from "../../utils/task_canceller";
import type CoreInterface from "../core_interface/types";
import type { IContentProtection } from "../decrypt";
import type IContentDecryptor from "../decrypt";
import { ContentDecryptorState, getKeySystemConfiguration } from "../decrypt";
import type { ITextDisplayer } from "../text_displayer";
import { MainThreadMessageType } from "../types";
import { canHandleTextTracks } from "../utils/media_capabilities";
import type { ITextDisplayerOptions } from "./types";
import { ContentInitializer } from "./types";
import type { ICorePlaybackObservation } from "./utils/create_core_playback_observer";
import createCorePlaybackObserver from "./utils/create_core_playback_observer";
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 RebufferingController from "./utils/rebuffering_controller";
import StreamEventsEmitter from "./utils/stream_events_emitter/stream_events_emitter";
import listenToMediaError from "./utils/throw_on_media_error";
import { updateManifestCodecSupport } from "./utils/update_manifest_codec_support";
const generateContentId = idGenerator();
/**
* @class MediaSourceContentInitializer
*/
export default class MediaSourceContentInitializer extends ContentInitializer {
/** Constructor settings associated to this `MediaSourceContentInitializer`. */
private _settings: IInitializeArguments;
/**
* The Core may be sending messages as soon as we're preparing the content but
* the `MediaSourceContentInitializer` is only able to handle all of them only
* once `start`ed.
*
* As such `_queuedCoreMessages` is set to an Array when `prepare` has been
* called but not `start` yet, and contains all core messages that have to
* be processed when `start` is called.
*
* It is set to `null` when there's no need to rely on that queue (either not
* yet `prepare`d or already `start`ed).
*/
private _queuedCoreMessages: ICoreMessage[] | null;
/**
* Information relative to the current loaded content.
*
* `null` when no content is prepared yet.
*/
private _currentContentInfo: IMediaSourceContentInitializerContentInfos | null;
/**
* `TaskCanceller` allowing to abort everything that the
* `MediaSourceContentInitializer` is doing.
*/
private _initCanceller: TaskCanceller;
/**
* `TaskCanceller` allowing to abort and clean-up every task and resource
* linked to the current `MediaSource` instance.
*
* It may be triggered either at content stop (and thus at the same time than
* the `_initCanceller`) or when reloading the content.
*/
private _currentMediaSourceCanceller: TaskCanceller;
private _awaitingRequests: {
nextRequestId: number;
/**
* Stores the resolvers and the current messageId that is sent to the core to
* receive segment sink metrics.
* The purpose of collecting metrics is for monitoring and debugging.
*/
pendingSinkMetrics: Map<
number /* request id */,
{
resolve: (value: ISegmentSinkMetrics | undefined) => void;
}
>;
/**
* Stores the resolvers and the current messageId that is sent to the web worker to
* receive image thumbnails.
*/
pendingThumbnailFetching: Map<
number /* request id */,
{
resolve: (value: IThumbnailResponse) => void;
reject: (error: Error) => void;
}
>;
};
/**
* Create a new `MediaSourceContentInitializer`, associated to the given
* settings.
* @param {Object} settings
*/
constructor(settings: IInitializeArguments) {
super();
this._settings = settings;
this._initCanceller = new TaskCanceller("Init");
this._currentMediaSourceCanceller = new TaskCanceller("Init MediaSource");
this._currentMediaSourceCanceller.linkToSignal(this._initCanceller.signal);
this._currentContentInfo = null;
this._awaitingRequests = {
nextRequestId: 0,
pendingSinkMetrics: new Map(),
pendingThumbnailFetching: new Map(),
};
this._queuedCoreMessages = null;
}
/**
* Perform non-destructive preparation steps, to prepare a future content.
*/
public prepare(): void {
if (this._currentContentInfo !== null || this._initCanceller.isUsed()) {
return;
}
const contentId = generateContentId();
const {
adaptiveOptions,
transport,
transportOptions,
useMseInWorker,
coreInterface,
} = this._settings;
const { wantedBufferAhead, maxVideoBufferSize, maxBufferAhead, maxBufferBehind } =
this._settings.bufferOptions;
const initialVideoBitrate = adaptiveOptions.initialBitrates.video;
const initialAudioBitrate = adaptiveOptions.initialBitrates.audio;
this._currentContentInfo = {
contentId,
contentDecryptor: null,
manifest: null,
mediaSourceInfo: null,
rebufferingController: null,
initialTime: undefined,
autoPlay: undefined,
initialPlayPerformed: null,
useMseInWorker,
};
coreInterface.sendMessage({
type: MainThreadMessageType.PrepareContent,
value: {
contentId,
cmcd: this._settings.cmcd,
enableRepresentationAvoidance: this._settings.enableRepresentationAvoidance,
url: this._settings.url,
hasText: canHandleTextTracks(this._settings.textTrackOptions),
transport,
transportOptions,
initialVideoBitrate,
initialAudioBitrate,
manifestRetryOptions: {
...this._settings.manifestRequestSettings,
lowLatencyMode: this._settings.lowLatencyMode,
},
segmentRetryOptions: this._settings.segmentRequestOptions,
useMseInWorker,
},
});
this._initCanceller.signal.register(() => {
coreInterface.sendMessage({
type: MainThreadMessageType.StopContent,
contentId,
value: null,
});
});
if (this._initCanceller.isUsed()) {
return;
}
this._queuedCoreMessages = [];
log.debug("Init", "addEventListener prepare buffering core messages");
const onmessage = (msgData: ICoreMessage): void => {
if (msgData.type !== CoreMessageType.LogMessage) {
log.debug("Init", "Core message received", msgData.type);
}
const type = msgData.type;
switch (type) {
case CoreMessageType.LogMessage: {
const formatted: IAcceptedLogValue[] = msgData.value.logs.map((l) => {
switch (typeof l) {
case "string":
case "number":
case "boolean":
case "undefined":
return l;
case "object":
if (l === null) {
return null;
}
return formatSentLogObject(l);
default:
assertUnreachable(l);
}
});
switch (msgData.value.logLevel) {
case "NONE":
break;
case "ERROR":
log.error(msgData.value.namespace, ...formatted);
break;
case "WARNING":
log.warn(msgData.value.namespace, ...formatted);
break;
case "INFO":
log.info(msgData.value.namespace, ...formatted);
break;
case "DEBUG":
log.debug(msgData.value.namespace, ...formatted);
break;
default:
assertUnreachable(msgData.value.logLevel);
}
break;
}
default:
if (this._queuedCoreMessages !== null) {
this._queuedCoreMessages.push(msgData);
}
break;
}
};
this._settings.coreInterface.addMessageListener(onmessage);
const onmessageerror = () => {
log.error("Init", "Error when receiving message from core.");
};
this._settings.coreInterface.addErrorListener(onmessageerror);
this._initCanceller.signal.register(() => {
log.debug("Init", "removeEventListener prepare for core message");
this._settings.coreInterface.removeMessageListener(onmessage);
this._settings.coreInterface.removeErrorListener(onmessageerror);
});
// Also bind all `SharedReference` objects:
const throttleVideoBitrate =
adaptiveOptions.throttlers.throttleBitrate.video ?? new SharedReference(Infinity);
bindNumberReferencesToCore(
coreInterface,
this._initCanceller.signal,
[wantedBufferAhead, "wantedBufferAhead"],
[maxVideoBufferSize, "maxVideoBufferSize"],
[maxBufferAhead, "maxBufferAhead"],
[maxBufferBehind, "maxBufferBehind"],
[throttleVideoBitrate, "throttleVideoBitrate"],
);
const limitVideoResolution =
adaptiveOptions.throttlers.limitResolution.video ??
new SharedReference<IResolutionInfo>({
height: undefined,
width: undefined,
pixelRatio: 1,
});
limitVideoResolution.onUpdate(
(newVal) => {
coreInterface.sendMessage({
type: MainThreadMessageType.ReferenceUpdate,
value: { name: "limitVideoResolution", newVal },
});
},
{ clearSignal: this._initCanceller.signal, emitCurrentValue: true },
);
}
/**
* 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 {
if (this._currentContentInfo === null) {
return;
}
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.ContentUrlsUpdate,
contentId: this._currentContentInfo.contentId,
value: { urls, refreshNow },
});
}
/**
* @param {HTMLMediaElement} mediaElement
* @param {Object} playbackObserver
*/
public start(
mediaElement: IMediaElement,
playbackObserver: IMediaElementPlaybackObserver,
): void {
this.prepare(); // Load Manifest if not already done
if (this._initCanceller.isUsed()) {
return;
}
let textDisplayer: ITextDisplayer | null = null;
try {
if (
this._settings.textTrackOptions.textTrackMode === "html" &&
features.htmlTextDisplayer !== null
) {
assert(canHandleTextTracks(this._settings.textTrackOptions));
textDisplayer = new features.htmlTextDisplayer(
mediaElement,
this._settings.textTrackOptions.textTrackElement,
);
} else if (features.nativeTextDisplayer !== null) {
assert(canHandleTextTracks(this._settings.textTrackOptions));
textDisplayer = new features.nativeTextDisplayer(mediaElement);
} else {
assert(!canHandleTextTracks(this._settings.textTrackOptions));
}
} catch (err) {
log.error(
"Init",
"failed to initialize text displayer",
err instanceof Error ? err : "Unknown Error",
);
}
this._initCanceller.signal.register((err) => {
textDisplayer?.stop(err.reason);
});
/** Translate errors coming from the media element into RxPlayer errors. */
listenToMediaError(
mediaElement,
(error: MediaError) => this._onFatalError(error),
this._initCanceller.signal,
);
/**
* Send content protection initialization data.
* TODO remove and use ContentDecryptor directly when possible.
*/
const lastContentProtection = new SharedReference<IContentProtection | null>(null);
const mediaSourceStatus = new SharedReference<MediaSourceInitializationStatus>(
MediaSourceInitializationStatus.Nothing,
);
const { statusRef: drmInitializationStatus, contentDecryptor } =
this._initializeContentDecryption(
mediaElement,
lastContentProtection,
mediaSourceStatus,
() => notifyAndStartMediaSourceReload(0, undefined, undefined),
this._initCanceller.signal,
);
const contentInfo = this._currentContentInfo;
if (contentInfo !== null) {
contentInfo.contentDecryptor = contentDecryptor;
}
const streamEventsEmitter = new StreamEventsEmitter(playbackObserver);
streamEventsEmitter.addEventListener(
"event",
(payload) => {
this.trigger("streamEvent", payload);
},
this._initCanceller.signal,
);
streamEventsEmitter.addEventListener(
"eventSkip",
(payload) => {
this.trigger("streamEventSkip", payload);
},
this._initCanceller.signal,
);
this._initCanceller.signal.register((err) => {
streamEventsEmitter.stop(err.reason);
});
const playbackStartParams = {
mediaElement,
textDisplayer,
playbackObserver,
drmInitializationStatus,
mediaSourceStatus,
streamEventsEmitter,
};
mediaSourceStatus.onUpdate(
(msInitStatus, stopListeningMSStatus) => {
if (msInitStatus === MediaSourceInitializationStatus.Attached) {
stopListeningMSStatus();
this._startPlaybackIfReady(playbackStartParams);
}
},
{ clearSignal: this._initCanceller.signal, emitCurrentValue: true },
);
drmInitializationStatus.onUpdate(
(initializationStatus, stopListeningDrm) => {
if (initializationStatus.initializationState.type === "initialized") {
stopListeningDrm();
this._startPlaybackIfReady(playbackStartParams);
}
},
{ emitCurrentValue: true, clearSignal: this._initCanceller.signal },
);
/**
* Reset directly (synchronously) the current `MediaSource` and signal to
* the core that we did so.
* @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.
*/
const notifyAndStartMediaSourceReload = (
deltaPosition: number,
minimumPosition: number | undefined,
maximumPosition: number | undefined,
): void => {
const reloadingContentInfo = this._currentContentInfo;
if (reloadingContentInfo === null) {
log.warn("Init", "Asked to reload when no content is loaded.");
return;
}
if (
reloadingContentInfo === null ||
reloadingContentInfo.mediaSourceInfo === null
) {
log.warn("Init", "Asked to reload when no MediaSource is active.");
return;
}
const mediaSourceId =
reloadingContentInfo.mediaSourceInfo.type === "main"
? reloadingContentInfo.mediaSourceInfo.mediaSource.id
: reloadingContentInfo.mediaSourceInfo.mediaSourceId;
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.MediaSourceReload,
mediaSourceId,
value: null,
});
reloadMediaSource(deltaPosition, minimumPosition, maximumPosition);
};
/**
* Reset directly (synchronously) the current `MediaSource`.
*
* It is assumed that `core` already knows about this action. If not, call
* `notifyAndStartMediaSourceReload` instead.
* @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.
*/
const reloadMediaSource = (
deltaPosition: number,
minimumPosition: number | undefined,
maximumPosition: number | undefined,
): void => {
const reloadingContentInfo = this._currentContentInfo;
if (reloadingContentInfo === null) {
log.warn("Init", "Asked to reload when no content is loaded.");
return;
}
const lastObservation = playbackObserver.getReference().getValue();
const currentPosition = lastObservation.position.getWanted();
const isPaused =
reloadingContentInfo.initialPlayPerformed?.getValue() === true ||
reloadingContentInfo.autoPlay === undefined
? lastObservation.paused
: !reloadingContentInfo.autoPlay;
let position = currentPosition + deltaPosition;
if (minimumPosition !== undefined) {
position = Math.max(minimumPosition, position);
}
if (maximumPosition !== undefined) {
position = Math.min(maximumPosition, position);
}
this._reload(
mediaElement,
textDisplayer,
playbackObserver,
mediaSourceStatus,
streamEventsEmitter,
position,
!isPaused,
);
};
const onmessage = (msgData: ICoreMessage) => {
switch (msgData.type) {
case CoreMessageType.AttachMediaSource: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (this._currentContentInfo !== null) {
if (this._currentContentInfo.mediaSourceInfo?.type === "main") {
this._currentContentInfo.mediaSourceInfo.mediaSource.dispose(
"new AttachMediaSource message",
);
}
this._currentContentInfo.mediaSourceInfo = {
type: "core",
mediaSourceId: msgData.mediaSourceId,
};
}
const mediaSourceLink = msgData.value;
mediaSourceStatus.onUpdate(
(currStatus, stopListening) => {
if (currStatus === MediaSourceInitializationStatus.AttachNow) {
stopListening();
log.info("media", "Attaching MediaSource URL to the media element");
if (mediaSourceLink.type === "handle") {
mediaElement.srcObject = mediaSourceLink.value;
this._currentMediaSourceCanceller.signal.register(() => {
mediaElement.srcObject = null;
});
} else {
mediaElement.src = mediaSourceLink.value;
this._currentMediaSourceCanceller.signal.register(() => {
resetMediaElement(mediaElement, mediaSourceLink.value);
});
}
disableRemotePlaybackOnManagedMediaSource(
mediaElement,
this._currentMediaSourceCanceller.signal,
);
mediaSourceStatus.setValue(MediaSourceInitializationStatus.Attached);
}
},
{ emitCurrentValue: true, clearSignal: this._initCanceller.signal },
);
break;
}
case CoreMessageType.Warning:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("warning", formatCoreError(msgData.value));
break;
case CoreMessageType.Error:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this._onFatalError(formatCoreError(msgData.value));
break;
case CoreMessageType.CreateMediaSource:
this._onCreateMediaSourceMessage(
msgData,
mediaElement,
mediaSourceStatus,
this._settings.coreInterface,
);
break;
case CoreMessageType.AddSourceBuffer:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
mediaSource.addSourceBuffer(
msgData.value.sourceBufferType,
msgData.value.codec,
);
}
break;
case CoreMessageType.SourceBufferAppend:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer
.appendBuffer(msgData.value.data, msgData.value.params)
.then((buffered) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.SourceBufferSuccess,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value: { buffered },
});
})
.catch((error) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.SourceBufferError,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value:
error instanceof CancellationError
? { errorName: "CancellationError" }
: formatSourceBufferError(error).serialize(),
});
});
}
break;
case CoreMessageType.SourceBufferRemove:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer
.remove(msgData.value.start, msgData.value.end)
.then((buffered) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.SourceBufferSuccess,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value: { buffered },
});
})
.catch((error) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.SourceBufferError,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value:
error instanceof CancellationError
? { errorName: "CancellationError" }
: formatSourceBufferError(error).serialize(),
});
});
}
break;
case CoreMessageType.AbortSourceBuffer:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer.abort("received AbortSourceBuffer message");
}
break;
case CoreMessageType.UpdateMediaSourceDuration:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
if (mediaSource?.id !== msgData.mediaSourceId) {
return;
}
mediaSource.setDuration(msgData.value.duration, msgData.value.isRealEndKnown);
}
break;
case CoreMessageType.InterruptMediaSourceDurationUpdate:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
if (mediaSource?.id !== msgData.mediaSourceId) {
return;
}
mediaSource.interruptDurationSetting(
"received InterrupMediaSourceDurationUpdate message",
);
}
break;
case CoreMessageType.EndOfStream:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
mediaSource.maintainEndOfStream();
}
break;
case CoreMessageType.InterruptEndOfStream:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
mediaSource.stopEndOfStream();
}
break;
case CoreMessageType.DisposeMediaSource:
{
if (
this._currentContentInfo?.mediaSourceInfo?.type !== "main" ||
this._currentContentInfo.mediaSourceInfo.mediaSource.id !==
msgData.mediaSourceId
) {
return;
}
const { mediaSource } = this._currentContentInfo.mediaSourceInfo;
mediaSource.dispose("DisposeMediaSource message");
}
break;
case CoreMessageType.NeedsBufferFlush: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const lastObservation = playbackObserver.getReference().getValue();
const currentTime = lastObservation.position.isAwaitingFuturePosition()
? lastObservation.position.getWanted()
: mediaElement.currentTime;
const relativeResumingPosition = msgData.value?.relativeResumingPosition ?? 0;
const canBeApproximateSeek = Boolean(
msgData.value?.relativePosHasBeenDefaulted,
);
let wantedSeekingTime: number;
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);
break;
}
case CoreMessageType.ActivePeriodChanged: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period !== undefined) {
this.trigger("activePeriodChanged", { period });
}
break;
}
case CoreMessageType.AdaptationChanged: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period === undefined) {
return;
}
if (msgData.value.adaptationId === null) {
this.trigger("adaptationChange", {
period,
adaptation: null,
type: msgData.value.type,
});
return;
}
const adaptations = period.adaptations[msgData.value.type] ?? [];
const adaptation = arrayFind(
adaptations,
(a) => a.id === msgData.value.adaptationId,
);
if (adaptation !== undefined) {
this.trigger("adaptationChange", {
period,
adaptation,
type: msgData.value.type,
});
}
break;
}
case CoreMessageType.RepresentationChanged: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period === undefined) {
return;
}
if (msgData.value.representationId === null) {
this.trigger("representationChange", {
period,
type: msgData.value.type,
representation: null,
});
return;
}
const adaptations = period.adaptations[msgData.value.type] ?? [];
const adaptation = arrayFind(
adaptations,
(a) => a.id === msgData.value.adaptationId,
);
if (adaptation === undefined) {
return;
}
const representation = arrayFind(
adaptation.representations,
(r) => r.id === msgData.value.representationId,
);
if (representation !== undefined) {
this.trigger("representationChange", {
period,
type: msgData.value.type,
representation,
});
}
break;
}
case CoreMessageType.EncryptionDataEncountered:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
lastContentProtection.setValue(msgData.value);
break;
case CoreMessageType.ManifestReady: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const manifest = msgData.value.manifest;
streamEventsEmitter.start(manifest);
this._currentContentInfo.manifest = manifest;
this._updateCodecSupport(manifest, mediaElement);
this._startPlaybackIfReady(playbackStartParams);
break;
}
case CoreMessageType.ManifestUpdate: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const manifest = this._currentContentInfo?.manifest;
if (isNullOrUndefined(manifest)) {
log.error("Init", "Manifest update but no Manifest loaded");
return;
}
if (manifest.manifestFormat === ManifestMetadataFormat.MetadataObject) {
// If we're currently in possession of an `IManifestMetadata`, we need
// to replicate the updates sent by the Core on it.
// If we rely on **the** `Manifest` instance directly however, we already
// share it with Core and as such don't need to replicate the updates.
replicateUpdatesOnManifestMetadata(
manifest,
msgData.value.manifest,
msgData.value.updates,
);
}
streamEventsEmitter.onManifestUpdate(manifest);
this._updateCodecSupport(manifest, mediaElement);
this.trigger("manifestUpdate", msgData.value.updates);
break;
}
case CoreMessageType.UpdatePlaybackRate:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
playbackObserver.setPlaybackRate(msgData.value);
break;
case CoreMessageType.BitrateEstimateChange:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("bitrateEstimateChange", {
type: msgData.value.bufferType,
bitrate: msgData.value.bitrate,
});
break;
case CoreMessageType.InbandEvent:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("inbandEvents", msgData.value);
break;
case CoreMessageType.LockedStream: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period === undefined) {
return;
}
this._currentContentInfo.rebufferingController?.onLockedStream(
msgData.value.bufferType,
period,
);
break;
}
case CoreMessageType.PeriodStreamReady: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period === undefined) {
return;
}
const ref = new SharedReference<IAdaptationChoice | null | undefined>(
undefined,
);
ref.onUpdate(
(adapChoice) => {
if (this._currentContentInfo === null) {
ref.finish();
return;
}
if (!isNullOrUndefined(adapChoice)) {
adapChoice.representations.onUpdate(
(repChoice, stopListening) => {
if (this._currentContentInfo === null) {
stopListening();
return;
}
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.RepresentationUpdate,
contentId: this._currentContentInfo.contentId,
value: {
periodId: msgData.value.periodId,
adaptationId: adapChoice.adaptationId,
bufferType: msgData.value.bufferType,
choice: repChoice,
},
});
},
{ clearSignal: this._initCanceller.signal },
);
}
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.TrackUpdate,
contentId: this._currentContentInfo.contentId,
value: {
periodId: msgData.value.periodId,
bufferType: msgData.value.bufferType,
choice: isNullOrUndefined(adapChoice)
? adapChoice
: {
adaptationId: adapChoice.adaptationId,
switchingMode: adapChoice.switchingMode,
initialRepresentations: adapChoice.representations.getValue(),
relativeResumingPosition: adapChoice.relativeResumingPosition,
},
},
});
},
{ clearSignal: this._initCanceller.signal },
);
this.trigger("periodStreamReady", {
period,
type: msgData.value.bufferType,
adaptationRef: ref,
});
break;
}
case CoreMessageType.PeriodStreamCleared: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
this.trigger("periodStreamCleared", {
periodId: msgData.value.periodId,
type: msgData.value.bufferType,
});
break;
}
case CoreMessageType.DiscontinuityUpdate: {
if (
this._currentContentInfo?.contentId !== msgData.contentId ||
this._currentContentInfo.manifest === null
) {
return;
}
const period = arrayFind(
this._currentContentInfo.manifest.periods,
(p) => p.id === msgData.value.periodId,
);
if (period === undefined) {
log.warn("Init", "Discontinuity's Period not found", {
periodId: msgData.value.periodId,
});
return;
}
this._currentContentInfo.rebufferingController?.updateDiscontinuityInfo({
period,
bufferType: msgData.value.bufferType,
discontinuity: msgData.value.discontinuity,
position: msgData.value.position,
});
break;
}
case CoreMessageType.PushTextData: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn("text", "Received AddTextData message but no text displayer exists");
} else {
try {
const ranges = textDisplayer.pushTextData(msgData.value);
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.PushTextDataSuccess,
contentId: msgData.contentId,
value: { ranges },
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.PushTextDataError,
contentId: msgData.contentId,
value: { message },
});
}
}
break;
}
case CoreMessageType.RemoveTextData: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"text",
"Received RemoveTextData message but no text displayer exists",
);
} else {
try {
const ranges = textDisplayer.removeBuffer(
msgData.value.start,
msgData.value.end,
);
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.RemoveTextDataSuccess,
contentId: msgData.contentId,
value: { ranges },
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.RemoveTextDataError,
contentId: msgData.contentId,
value: { message },
});
}
}
break;
}
case CoreMessageType.ResetTextDisplayer: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"text",
"Received ResetTextDisplayer message but no text displayer exists",
);
} else {
textDisplayer.reset();
}
break;
}
case CoreMessageType.StopTextDisplayer: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"text",
"Received StopTextDisplayer message but no text displayer exists",
);
} else {
textDisplayer.stop("received StopTextDisplayer message");
}
break;
}
case CoreMessageType.ReloadingMediaSource:
{
if (
this._currentContentInfo === null ||
this._currentContentInfo.mediaSourceInfo === null
) {
return;
}
const mediaSourceId =
this._currentContentInfo.mediaSourceInfo.type === "main"
? this._currentContentInfo.mediaSourceInfo.mediaSource.id
: this._currentContentInfo.mediaSourceInfo.mediaSourceId;
if (mediaSourceId !== msgData.mediaSourceId) {
return;
}
reloadMediaSource(
msgData.value.timeOffset,
msgData.value.minimumPosition,
msgData.value.maximumPosition,
);
}
break;
case CoreMessageType.NeedsDecipherabilityFlush:
{
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const keySystem = getKeySystemConfiguration(mediaElement);
if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem?.[0])) {
notifyAndStartMediaSourceReload(0, undefined, undefined);
} else {
const lastObservation = playbackObserver.getReference().getValue();
const currentPosition = lastObservation.position.getWanted();
// simple seek close to the current position
// to flush the buffers
if (currentPosition + 0.001 < lastObservation.duration) {
playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001);
} else {
playbackObserver.setCurrentTime(currentPosition);
}
}
}
break;
case CoreMessageType.SegmentSinkStoreUpdate: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const sinkObj = this._awaitingRequests.pendingSinkMetrics.get(
msgData.value.requestId,
);
if (sinkObj !== undefined) {
sinkObj.resolve(msgData.value.segmentSinkMetrics);
} else {
log.error("Init", "Failed to send segment sink store update");
}
break;
}
case CoreMessageType.InitSuccess:
case CoreMessageType.InitError:
// Should already be handled by the API
break;
case CoreMessageType.LogMessage:
// Already handled by prepare's handler
break;
case CoreMessageType.ThumbnailDataResponse: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const tObj = this._awaitingRequests.pendingThumbnailFetching.get(
msgData.value.requestId,
);
if (tObj !== undefined) {
if (msgData.value.status === "error") {
tObj.reject(formatCoreError(msgData.value.error));
} else {
tObj.resolve(msgData.value.data);
}
} else {
log.error("Init", "Failed to send segment sink store update");
}
break;
}
case CoreMessageType.AppDefined:
break;
default:
assertUnreachable(msgData);
}
};
log.debug("Init", "addEventListener for core message");
if (this._queuedCoreMessages !== null) {
const bufferedMessages = this._queuedCoreMessages.slice();
log.debug("Init", "Processing buffered messages", {
ammount: bufferedMessages.length,
});
for (const message of bufferedMessages) {
onmessage(message);
}
this._queuedCoreMessages = null;
}
this._settings.coreInterface.addMessageListener(onmessage);
this._initCanceller.signal.register(() => {
log.debug("Init", "removeEventListener for core message");
this._settings.coreInterface.removeMessageListener(onmessage);
});
}
public dispose(reason: string | undefined): void {
this._initCanceller.cancel("Init dispose");
if (this._currentContentInfo !== null) {
if (this._currentContentInfo.mediaSourceInfo?.type === "main") {
this._currentContentInfo.mediaSourceInfo.mediaSource.dispose(reason);
}
this._currentContentInfo = null;
}
}
private _onFatalError(err: unknown) {
if (this._initCanceller.isUsed()) {
return;
}
this._initCanceller.cancel("Init dispose due to fatal Error");
this.trigger("error", err);
}
private _initializeContentDecryption(
mediaElement: IMediaElement,
lastContentProtection: IReadOnlySharedReference<null | IContentProtection>,
mediaSourceStatus: SharedReference<MediaSourceInitializationStatus>,
reloadMediaSource: () => void,
cancelSignal: CancellationSignal,
): {
statusRef: IReadOnlySharedReference<IDrmInitializationStatus>;
contentDecryptor: IContentDecryptor | null;
} {
const { keySystems } = this._settings;
// TODO private?
const createEmeDisabledReference = (errMsg: string) => {
mediaSourceStatus.setValue(MediaSourceInitializationStatus.AttachNow);
lastContentProtection.onUpdate(
(data, stopListening) => {
if (data === null) {
// initial value
return;
}
stopListening();
const err = new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", errMsg, {
keyStatuses: undefined,
keySystemConfiguration: undefined,
keySystem: undefined,
});
this._onFatalError(err);
},
{ clearSignal: cancelSignal },
);
const ref = new SharedReference({
initializationState: {
type: "initialized" as const,
value: null,
},
contentDecryptor: null,
drmSystemId: undefined,
});
ref.finish(); // We know that no new value will be triggered
return { statusRef: ref, contentDecryptor: null };
};
if (keySystems.length === 0) {
return createEmeDisabledReference("No `keySystems` option given.");
} else if (features.decrypt === null) {
return createEmeDisabledReference("EME feature not activated.");
}
const emeApi = mediaElement.FORCED_EME_API ?? getEmeApiImplementation("auto");
if (emeApi === null) {
return createEmeDisabledReference("EME API not available on the current page.");
}
log.debug("Init", "Creating ContentDecryptor");
const ContentDecryptor = features.decrypt;
const contentDecryptor = new ContentDecryptor(emeApi, mediaElement, keySystems);
const drmStatusRef = new SharedReference<IDrmInitializationStatus>(
{
initializationState: { type: "uninitialized", value: null },
drmSystemId: undefined,
},
cancelSignal,
);
const updateCodecSupportOnStateChange = (state: ContentDecryptorState) => {
if (state > ContentDecryptorState.Initializing) {
const manifest = this._currentContentInfo?.manifest;
if (isNullOrUndefined(manifest)) {
return;
}
this._updateCodecSupport(manifest, mediaElement);
contentDecryptor.removeEventListener(
"stateChange",
updateCodecSupportOnStateChange,
);
}
};
contentDecryptor.addEventListener("stateChange", updateCodecSupportOnStateChange);
contentDecryptor.addEventListener("keyIdsCompatibilityUpdate", (updates) => {
if (
this._currentContentInfo === null ||
this._currentContentInfo.manifest === null
) {
return;
}
const manUpdates = updateDecipherabilityFromKeyIds(
this._currentContentInfo.manifest,
updates,
);
if (
mayMediaElementFailOnUndecipherableData() &&
manUpdates.some((e) => e.representation.decipherable !== true)
) {
reloadMediaSource();
} else {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.DecipherabilityStatusUpdate,
contentId: this._currentContentInfo.contentId,
value: manUpdates.map((s) => ({
representationUniqueId: s.representation.uniqueId,
decipherable: s.representation.decipherable,
})),
});
}
this.trigger("decipherabilityUpdate", manUpdates);
});
contentDecryptor.addEventListener("blackListProtectionData", (protData) => {
if (
this._currentContentInfo === null ||
this._currentContentInfo.manifest === null
) {
return;
}
const manUpdates = updateDecipherabilityFromProtectionData(
this._currentContentInfo.manifest,
protData,
);
if (
mayMediaElementFailOnUndecipherableData() &&
manUpdates.some((e) => e.representation.decipherable !== true)
) {
reloadMediaSource();
} else {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.DecipherabilityStatusUpdate,
contentId: this._currentContentInfo.contentId,
value: manUpdates.map((s) => ({
representationUniqueId: s.representation.uniqueId,
decipherable: s.representation.decipherable,
})),
});
}
this.trigger("decipherabilityUpdate", manUpdates);
});
contentDecryptor.addEventListener("stateChange", (state) => {
if (state === ContentDecryptorState.WaitingForAttachment) {
mediaSourceStatus.onUpdate(
(currStatus, stopListening) => {
if (currStatus === MediaSourceInitializationStatus.Nothing) {
mediaSourceStatus.setValue(MediaSourceInitializationStatus.AttachNow);
} else if (currStatus === MediaSourceInitializationStatus.Attached) {
stopListening();
if (state === ContentDecryptorState.WaitingForAttachment) {
contentDecryptor.attach();
}
}
},
{ clearSignal: cancelSignal, emitCurrentValue: true },
);
} else if (state === ContentDecryptorState.ReadyForContent) {
drmStatusRef.setValue({
initializationState: { type: "initialized", value: null },
drmSystemId: contentDecryptor.systemId,
});
contentDecryptor.removeEventListener("stateChange");
}
});
contentDecryptor.addEventListener("error", (error) => {
this._onFatalError(error);
});
contentDecryptor.addEventListener("warning", (error) => {
this.trigger("warning", error);
});
lastContentProtection.onUpdate(
(data) => {
if (data === null) {
return;
}
contentDecryptor.onInitializationData(data);
},
{ clearSignal: cancelSignal },
);
cancelSignal.register((err) => {
contentDecryptor.dispose(err.reason);
});
return { statusRef: drmStatusRef, contentDecryptor };
}
/**
* Retrieves all unknown codecs from the current manifest, checks these unknown codecs
* to determine if they are supported, updates the manifest with the support
* status of these codecs, and forwards the list of supported codecs to core.
* @param manifest
*/
private _updateCodecSupport(manifest: IManifestMetadata, mediaElement: IMediaElement) {
try {
const updatedCodecs = updateManifestCodecSupport(
manifest,
this._currentContentInfo?.contentDecryptor ?? null,
mediaElement,
this._currentContentInfo?.useMseInWorker ?? false,
);
if (updatedCodecs.length > 0) {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.CodecSupportUpdate,
value: updatedCodecs,
});
// TODO what if one day the core updates codec support by itself?
// We wouldn't know...
this.trigger("codecSupportUpdate", null);
}
} catch (err) {
this._onFatalError(err);
}
}
private _reload(
mediaElement: IMediaElement,
textDisplayer: ITextDisplayer | null,
playbackObserver: IMediaElementPlaybackObserver,
mediaSourceStatus: SharedReference<MediaSourceInitializationStatus>,
streamEventsEmitter: StreamEventsEmitter,
position: number,
autoPlay: boolean,
) {
this._currentMediaSourceCanceller.cancel("Init MediaSource Reload");
this._currentMediaSourceCanceller = new TaskCanceller("Init MediaSource");
this._currentMediaSourceCanceller.linkToSignal(this._initCanceller.signal);
mediaSourceStatus.setValue(MediaSourceInitializationStatus.AttachNow);
streamEventsEmitter.pause();
this.trigger("reloadingMediaSource", { position, autoPlay });
mediaSourceStatus.onUpdate(
(status, stopListeningMSStatusUpdates) => {
if (status !== MediaSourceInitializationStatus.Attached) {
return;
}
stopListeningMSStatusUpdates();
const corePlaybackObserver = this._setUpModulesOnNewMediaSource(
{
initialTime: position,
autoPlay,
mediaElement,
textDisplayer,
playbackObserver,
streamEventsEmitter,
},
this._currentMediaSourceCanceller.signal,
);
if (
!this._currentMediaSourceCanceller.isUsed() &&
corePlaybackObserver !== null &&
this._currentContentInfo !== null
) {
const contentId = this._currentContentInfo.contentId;
corePlaybackObserver.listen(
(obs) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.PlaybackObservation,
contentId,
value: objectAssign(obs, {
position: obs.position.serialize(),
}),
});
},
{
includeLastObservation: true,
clearSignal: this._currentMediaSourceCanceller.signal,
},
);
}
},
{
clearSignal: this._currentMediaSourceCanceller.signal,
emitCurrentValue: true,
},
);
}
/**
* Start-up modules and mechanisms (initial seek, auto-play etc.) needed each
* time a content is loaded AND re-loaded on a `HTMLMediaElement`, when the
* manifest is known.
*
* Note that this does not include reacting to incoming core messages nor
* sending them, those actions have to be handled separately.
*
* @param {Object} parameters
* @param {Object} cancelSignal
* @returns {Object|null} - Playback Observer created for this content. `null`
* only if playback initialization failed (most likely because it has been
* cancelled).
*/
private _setUpModulesOnNewMediaSource(
parameters: {
initialTime: number;
autoPlay: boolean;
mediaElement: IMediaElement;
textDisplayer: ITextDisplayer | null;
playbackObserver: IMediaElementPlaybackObserver;
streamEventsEmitter: StreamEventsEmitter;
},
cancelSignal: CancellationSignal,
): IReadOnlyPlaybackObserver<ICorePlaybackObservation> | null {
if (cancelSignal.isCancelled()) {
return null;
}
if (this._currentContentInfo === null) {
log.error("Init", "Setting up modules without a contentId");
return null;
}
if (this._currentContentInfo.manifest === null) {
log.error("Init", "Setting up modules without a loaded Manifest");
return null;
}
const { manifest, mediaSourceInfo } = this._currentContentInfo;
const { speed } = this._settings;
const {
initialTime,
autoPlay,
mediaElement,
textDisplayer,
playbackObserver,
streamEventsEmitter,
} = parameters;
this._currentContentInfo.initialTime = initialTime;
this._currentContentInfo.autoPlay = autoPlay;
streamEventsEmitter.pause(); // Only start polling events once ready to play
const { autoPlayResult, initialPlayPerformed } = performInitialSeekAndPlay(
{
mediaElement,
playbackObserver,
startTime: initialTime,
mustAutoPlay: autoPlay,
onWarning: (err) => this.trigger("warning", err),
isDirectfile: false,
},
cancelSignal,
);
this._currentContentInfo.initialPlayPerformed = initialPlayPerformed;
initialPlayPerformed.onUpdate(
(isPerformed, stopListening) => {
if (isPerformed) {
stopListening();
streamEventsEmitter.resume(); // We can now start polling events
}
},
{ clearSignal: cancelSignal, emitCurrentValue: true },
);
const corePlaybackObserver = createCorePlaybackObserver(
playbackObserver,
{
autoPlay,
initialPlayPerformed,
manifest,
mediaSource:
mediaSourceInfo?.type === "main" ? mediaSourceInfo.mediaSource : null,
speed,
textDisplayer,
},
cancelSignal,
);
if (cancelSignal.isCancelled()) {
return null;
}
/**
* Class trying to avoid various stalling situations, emitting "stalled"
* events when it cannot, as well as "unstalled" events when it get out of one.
*/
const rebufferingController = new RebufferingController(
playbackObserver,
manifest,
speed,
);
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((err) => {
rebufferingController.destroy(err.reason);
});
rebufferingController.start();
this._currentContentInfo.rebufferingController = rebufferingController;
const _getSegmentSinkMetrics = async (): Promise<ISegmentSinkMetrics | undefined> => {
this._awaitingRequests.nextRequestId++;
const requestId = this._awaitingRequests.nextRequestId;
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.PullSegmentSinkStoreInfos,
value: { requestId },
});
return new Promise((resolve, reject) => {
const rejectFn = (err: CancellationError) => {
cancelSignal.deregister(rejectFn);
this._awaitingRequests.pendingSinkMetrics.delete(requestId);
return reject(err);
};
this._awaitingRequests.pendingSinkMetrics.set(requestId, {
resolve: (value: ISegmentSinkMetrics | undefined) => {
cancelSignal.deregister(rejectFn);
this._awaitingRequests.pendingSinkMetrics.delete(requestId);
resolve(value);
},
});
cancelSignal.register(rejectFn);
});
};
const _getThumbnailsData = async (
periodId: string,
thumbnailTrackId: string,
time: number,
): Promise<IThumbnailResponse> => {
if (this._currentContentInfo === null) {
return Promise.reject(new Error("Cannot fetch thumbnails: No content loaded."));
}
this._awaitingRequests.nextRequestId++;
const requestId = this._awaitingRequests.nextRequestId;
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.ThumbnailDataRequest,
contentId: this._currentContentInfo.contentId,
value: { requestId, periodId, thumbnailTrackId, time },
});
return new Promise((resolve, reject) => {
const rejectFn = (err: CancellationError) => {
cleanUp();
reject(err);
};
const cleanUp = () => {
cancelSignal.deregister(rejectFn);
this._awaitingRequests.pendingThumbnailFetching.delete(requestId);
};
this._awaitingRequests.pendingThumbnailFetching.set(requestId, {
resolve: (value: IThumbnailResponse) => {
cleanUp();
resolve(value);
},
reject: (value: unknown) => {
cleanUp();
reject(value);
},
});
cancelSignal.register(rejectFn);
});
};
/**
* 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: _getSegmentSinkMetrics,
getThumbnailData: _getThumbnailsData,
});
}
},
{ emitCurrentValue: true, clearSignal: cancelSignal },
);
})
.catch((err) => {
if (cancelSignal.isCancelled()) {
return;
}
this._onFatalError(err);
});
return corePlaybackObserver;
}
/**
* Initialize content playback if and only if those conditions are filled:
* - The Manifest is fetched and stored in `this._currentContentInfo`.
* - `drmInitializationStatus` indicates that DRM matters are initialized.
* - `mediaSourceStatus` indicates that the MediaSource is attached to the
* `mediaElement`.
*
* In other cases, this method will do nothing.
*
* To call when any of those conditions might become `true`, to start-up
* playback.
*
* @param {Object} parameters
* @returns {boolean} - Returns `true` if all conditions where met for
* playback start.
*/
private _startPlaybackIfReady(parameters: {
mediaElement: IMediaElement;
textDisplayer: ITextDisplayer | null;
playbackObserver: IMediaElementPlaybackObserver;
drmInitializationStatus: IReadOnlySharedReference<IDrmInitializationStatus>;
mediaSourceStatus: IReadOnlySharedReference<MediaSourceInitializationStatus>;
streamEventsEmitter: StreamEventsEmitter;
}): boolean {
if (this._currentContentInfo === null || this._currentContentInfo.manifest === null) {
return false;
}
const drmInitStatus = parameters.drmInitializationStatus.getValue();
if (drmInitStatus.initializationState.type !== "initialized") {
return false;
}
const msInitStatus = parameters.mediaSourceStatus.getValue();
if (msInitStatus !== MediaSourceInitializationStatus.Attached) {
return false;
}
const { contentId, manifest } = this._currentContentInfo;
log.debug("Init", "Calculating initial time");
const initialTime = getInitialTime(
manifest,
this._settings.lowLatencyMode,
this._settings.startAt,
);
log.debug("Init", "Initial time calculated", { initialTime });
const { enableFastSwitching, onCodecSwitch } = this._settings.bufferOptions;
const corePlaybackObserver = this._setUpModulesOnNewMediaSource(
{
initialTime,
autoPlay: this._settings.autoPlay,
mediaElement: parameters.mediaElement,
textDisplayer: parameters.textDisplayer,
playbackObserver: parameters.playbackObserver,
streamEventsEmitter: parameters.streamEventsEmitter,
},
this._currentMediaSourceCanceller.signal,
);
if (this._currentMediaSourceCanceller.isUsed() || corePlaybackObserver === null) {
return true;
}
const initialObservation = corePlaybackObserver.getReference().getValue();
const sentInitialObservation = objectAssign(initialObservation, {
position: initialObservation.position.serialize(),
});
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.StartPreparedContent,
contentId,
value: {
initialTime,
initialObservation: sentInitialObservation,
drmSystemId: drmInitStatus.drmSystemId,
enableFastSwitching,
onCodecSwitch,
},
});
corePlaybackObserver.listen(
(obs) => {
this._settings.coreInterface.sendMessage({
type: MainThreadMessageType.PlaybackObservation,
contentId,
value: objectAssign(obs, { position: obs.position.serialize() }),
});
},
{
includeLastObservation: false,
clearSignal: this._currentMediaSourceCanceller.signal,
},
);
this.trigger("manifestReady", manifest);
return true;
}
/**
* Handles core messages asking to create a MediaSource.
* @param {Object} msg - The core's message received.
* @param {HTMLMediaElement} mediaElement - HTMLMediaElement on which the
* content plays.
* @param {Object} coreInterface - The interface to the core.
*/
private _onCreateMediaSourceMessage(
msg: ICreateMediaSourceCoreMessage,
mediaElement: IMediaElement,
mediaSourceStatus: SharedReference<MediaSourceInitializationStatus>,
coreInterface: CoreInterface,
): void {
if (this._currentContentInfo?.contentId !== msg.contentId) {
log.info("Init", "Ignoring MediaSource attachment due to wrong `contentId`");
} else {
const { mediaSourceId } = msg;
try {
mediaSourceStatus.onUpdate(
(currStatus, stopListening) => {
if (this._currentContentInfo === null) {
stopListening();
return;
}
if (currStatus === MediaSourceInitializationStatus.AttachNow) {
stopListening();
const mediaSource = new MainMediaSourceInterface(
mediaSourceId,
"FORCED_MEDIA_SOURCE" in mediaElement
? mediaElement.FORCED_MEDIA_SOURCE
: undefined,
);
if (this._currentContentInfo.mediaSourceInfo?.type === "main") {
this._currentContentInfo.mediaSourceInfo.mediaSource.dispose(
"Attaching new MediaSource",
);
}
this._currentContentInfo.mediaSourceInfo = {
type: "main",
mediaSource,
};
mediaSource.addEventListener("mediaSourceOpen", () => {
coreInterface.sendMessage({
type: MainThreadMessageType.MediaSourceReadyStateChange,
mediaSourceId,
value: "open",
});
});
mediaSource.addEventListener("mediaSourceEnded", () => {
coreInterface.sendMessage({
type: MainThreadMessageType.MediaSourceReadyStateChange,
mediaSourceId,
value: "ended",
});
});
mediaSource.addEventListener("mediaSourceClose", () => {
coreInterface.sendMessage({
type: MainThreadMessageType.MediaSourceReadyStateChange,
mediaSourceId,
value: "closed",
});
});
let url: string | null = null;
if (mediaSource.handle.type === "handle") {
mediaElement.srcObject = mediaSource.handle.value;
} else {
url = URL.createObjectURL(mediaSource.handle.value);
mediaElement.src = url;
}
this._currentMediaSourceCanceller.signal.register((err) => {
mediaSource.dispose(err.reason);
resetMediaElement(mediaElement, url);
});
mediaSourceStatus.setValue(MediaSourceInitializationStatus.Attached);
disableRemotePlaybackOnManagedMediaSource(
mediaElement,
this._currentMediaSourceCanceller.signal,
);
}
},
{
emitCurrentValue: true,
clearSignal: this._currentMediaSourceCanceller.signal,
},
);
} catch (_err) {
const error = new OtherError(
"NONE",
"Unknown error when creating the MediaSource",
);
this._onFatalError(error);
}
}
}
}
export interface IMediaSourceContentInitializerContentInfos {
/**
* "contentId", which is the identifier for the currently loaded content.
* Allows to ensure that the Core is referencing the current content, not
* a previously stopped one.
*/
contentId: string;
/**
* Current parsed Manifest.
* `null` if not fetched / parsed yet.
*/
manifest: IManifestMetadata | null;
/**
* Current MediaSource linked to the content.
*
* `null` if no MediaSource is currently created for the content.
*/
mediaSourceInfo:
| {
type: "main";
mediaSource: MainMediaSourceInterface;
}
| {
type: "core";
mediaSourceId: string;
}
| null;
/**
* Current `RebufferingController` linked to the content, allowing to
* detect and handle rebuffering situations.
*
* `null` if none is currently created for the content.
*/
rebufferingController: RebufferingController | null;
/**
* The initial position to seek to in seconds once the content is loadeed.
* `undefined` if unknown yet.
*/
initialTime: number | undefined;
/**
* Whether to automatically play once the content is loaded.
* `undefined` if unknown yet.
*/
autoPlay: boolean | undefined;
/**
* Set to `true` once the initial play (or skipping the initial play when
* autoplay is not enabled) has been done.
* Set to `false` when it hasn't been done yet.
*
* Set to `null` when those considerations are not taken yet.
*/
initialPlayPerformed: IReadOnlySharedReference<boolean> | null;
/**
* Set to the initialized `ContentDecryptor` instance linked to that content.
*
* Set to `null` when those considerations are not taken.
*/
contentDecryptor: IContentDecryptor | null;
/**
* If `true`, MSE API should be used in the core part of the RxPlayer (in the
* WebWorker).
* If `false`, they should be relied on on main thread.
*/
useMseInWorker: boolean;
}
/** Arguments to give to the `InitializeOnMediaSource` function. */
export interface IInitializeArguments {
/**
* The `MediaSourceContentInitializer` will interact with the RxPlayer's core
* logic (the one loading media data) by exchanging messages through an
* interface called the `CoreInterface`.
*
* This `CoreInterface` allows to abstract its actual current implementation.
* E.g., the core logic could be running in a WebWorker or in main thread, in
* which cases message exchanging mechanisms would be different.
*/
coreInterface: CoreInterface;
/**
* If `true`, MSE API should be used in the core part of the RxPlayer (in the
* WebWorker).
* If `false`, they should be relied on on main thread.
*
* This might depend on both browser capabilities and preferences. It is
* assumed that the caller perform all those checks, the `ContentInitializer`
* won't check again the validity of this value.
*/
useMseInWorker: boolean;
/** 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;
/**
* The type of "transport" wanted, e.g. "dash" or "smooth".
*/
transport: string;
/** Options relative to the streaming protocol. */
transportOptions: Omit<
ITransportOptions,
"representationFilter" | "manifestLoader" | "segmentLoader"
> & {
manifestLoader:
| undefined
| {
fn?: IManifestLoader | undefined;
workerId?: string | undefined;
};
segmentLoader:
| undefined
| {
fn?: ISegmentLoader | undefined;
workerId?: string | undefined;
};
representationFilter:
| undefined
| {
fn?: IRepresentationFilter | undefined;
eval?: string | undefined;
workerId?: string | undefined;
};
};
/** 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;
};
/** 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;
}
function bindNumberReferencesToCore(
coreInterface: CoreInterface,
cancellationSignal: CancellationSignal,
...refs: Array<
[
IReadOnlySharedReference<number>,
(
| "wantedBufferAhead"
| "maxVideoBufferSize"
| "maxBufferBehind"
| "maxBufferAhead"
| "throttleVideoBitrate"
),
]
>
): void {
for (const ref of refs) {
ref[0].onUpdate(
(newVal) => {
// NOTE: The TypeScript checks have already been made by this function's
// overload, but the body here is not aware of that.
coreInterface.sendMessage({
type: MainThreadMessageType.ReferenceUpdate,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-type-assertion
value: { name: ref[1] as any, newVal: newVal as any },
});
},
{ clearSignal: cancellationSignal, emitCurrentValue: true },
);
}
}
function formatCoreError(sentError: ISentError): IPlayerError {
switch (sentError.name) {
case "NetworkError":
return new NetworkError(
sentError.code,
new RequestError(
sentError.baseError.url,
sentError.baseError.status,
sentError.baseError.type,
),
);
case "MediaError":
return deserializeMediaError(sentError);
case "EncryptedMediaError":
// We assume that everything have already been checked Worker-side here
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return new EncryptedMediaError(sentError.code, sentError.reason, {
keyStatuses: sentError.keyStatuses,
keySystemConfiguration: sentError.keySystemConfiguration,
keySystem: sentError.keySystem,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
case "OtherError":
return new OtherError(sentError.code, sentError.reason);
}
}
/** Enume allowing to state what is the current status of MediaSource initialization. */
const enum MediaSourceInitializationStatus {
/**
* The `MediaSource` is not attached to the `HTMLMediaElement` and shouldn't
* be yet.
*/
Nothing,
/**
* The `MediaSource` is not yet attached to the `HTMLMediaElement` but it
* now can and should be.
*
* The purpose of this enum variant is to be set when wanting to indicate
* that `MediaSource` attachment has to be done, in code that do not have
* the capability to do so.
*
* The code that can do so would then read that value and then set this enum
* to `Attached` once the `MediaSource` is attached.
*/
AttachNow,
/** The `MediaSource` is attached to the `HTMLMediaElement`. */
Attached,
}
interface IDrmInitializationStatus {
/** Current initialization state the decryption logic is in. */
initializationState: IDecryptionInitializationState;
/**
* If set, corresponds to the hex string describing the current key system
* used.
* `undefined` if unknown or if it does not apply.
*/
drmSystemId: string | undefined;
}
/** Initialization steps to add decryption capabilities to an `HTMLMediaElement`. */
type IDecryptionInitializationState =
/**
* Decryption capabilities have not been initialized yet.
* You should wait before performing any action on the concerned
* `HTMLMediaElement` (such as linking a content / `MediaSource` to it).
*/
| { type: "uninitialized"; value: null }
/**
* The `MediaSource` or media url can be linked AND segments can be pushed to
* the `HTMLMediaElement` on which decryption capabilities were wanted.
*/
| {
type: "initialized";
value: null;
};
function formatSourceBufferError(error: unknown): SourceBufferError {
if (error instanceof SourceBufferError) {
return error;
} else if (error instanceof Error) {
return new SourceBufferError(
error.name,
error.message,
error.name === "QuotaExceededError",
);
} else {
return new SourceBufferError("Error", "Unknown SourceBufferError Error", false);
}
}
/**
* The Core might send back logs. In that situation, the message might be
* formatted slightly differently to be able to cross threads (so a
* serializable format has to be sent).
*
* This function translates that Core format to what's expected by the
* logger.
*
* @param {*} arg
* @returns {*}
*/
function formatSentLogObject(arg: ISentLogValue): IAcceptedLogValue {
if (typeof arg !== "object") {
return arg;
}
if (arg?.isSerializedError === true) {
return formatCoreError(arg as ISentError);
}
return arg as Exclude<ISentLogValue, ISentError>;
}