rx-player
Version:
Canal+ HTML5 Video Player
1,423 lines (1,345 loc) • 76.3 kB
text/typescript
import type { IMediaElement } from "../../compat/browser_compatibility_types";
import hasMseInWorker from "../../compat/has_mse_in_worker";
import mayMediaElementFailOnUndecipherableData from "../../compat/may_media_element_fail_on_undecipherable_data";
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,
} from "../../core/types";
import {
EncryptedMediaError,
MediaError,
NetworkError,
OtherError,
SourceBufferError,
} from "../../errors";
import features from "../../features";
import log from "../../log";
import type { IManifestMetadata } from "../../manifest";
import {
replicateUpdatesOnManifestMetadata,
updateDecipherabilityFromKeyIds,
updateDecipherabilityFromProtectionData,
} from "../../manifest";
import MainMediaSourceInterface from "../../mse/main_media_source_interface";
import type {
ICreateMediaSourceWorkerMessage,
ISentError,
IWorkerMessage,
} from "../../multithread_types";
import { MainThreadMessageType, WorkerMessageType } from "../../multithread_types";
import type {
IReadOnlyPlaybackObserver,
IMediaElementPlaybackObserver,
} from "../../playback_observer";
import type { IWorkerPlaybackObservation } from "../../playback_observer/worker_playback_observer";
import type {
ICmcdOptions,
IInitialManifest,
IKeySystemOption,
IPlayerError,
} 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 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 { IContentProtection } from "../decrypt";
import type IContentDecryptor from "../decrypt";
import { ContentDecryptorState, getKeySystemConfiguration } from "../decrypt";
import type { ITextDisplayer } from "../text_displayer";
import sendMessage from "./send_message";
import type { ITextDisplayerOptions } from "./types";
import { ContentInitializer } from "./types";
import createCorePlaybackObserver from "./utils/create_core_playback_observer";
import {
resetMediaElement,
disableRemotePlaybackOnManagedMediaSource,
} 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 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 MultiThreadContentInitializer
*/
export default class MultiThreadContentInitializer extends ContentInitializer {
/** Constructor settings associated to this `MultiThreadContentInitializer`. */
private _settings: IInitializeArguments;
/**
* The WebWorker may be sending messages as soon as we're preparing the
* content but the `MultiThreadContentInitializer` is only able to handle all of
* them only once `start`ed.
*
* As such `_queuedWorkerMessages` is set to an Array when `prepare` has been
* called but not `start` yet, and contains all worker 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 _queuedWorkerMessages: MessageEvent[] | null;
/**
* Information relative to the current loaded content.
*
* `null` when no content is prepared yet.
*/
private _currentContentInfo: IMultiThreadContentInitializerContentInfos | null;
/**
* `TaskCanceller` allowing to abort everything that the
* `MultiThreadContentInitializer` 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 web worker 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 `MultiThreadContentInitializer`, associated to the given
* settings.
* @param {Object} settings
*/
constructor(settings: IInitializeArguments) {
super();
this._settings = settings;
this._initCanceller = new TaskCanceller();
this._currentMediaSourceCanceller = new TaskCanceller();
this._currentMediaSourceCanceller.linkToSignal(this._initCanceller.signal);
this._currentContentInfo = null;
this._awaitingRequests = {
nextRequestId: 0,
pendingSinkMetrics: new Map(),
pendingThumbnailFetching: new Map(),
};
this._queuedWorkerMessages = 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, transportOptions, worker } = 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,
mainThreadMediaSource: null,
rebufferingController: null,
streamEventsEmitter: null,
initialTime: undefined,
autoPlay: undefined,
initialPlayPerformed: null,
};
sendMessage(worker, {
type: MainThreadMessageType.PrepareContent,
value: {
contentId,
cmcd: this._settings.cmcd,
enableRepresentationAvoidance: this._settings.enableRepresentationAvoidance,
url: this._settings.url,
hasText: this._hasTextBufferFeature(),
transportOptions,
initialVideoBitrate,
initialAudioBitrate,
manifestRetryOptions: {
...this._settings.manifestRequestSettings,
lowLatencyMode: this._settings.lowLatencyMode,
},
segmentRetryOptions: this._settings.segmentRequestOptions,
},
});
this._initCanceller.signal.register(() => {
sendMessage(worker, {
type: MainThreadMessageType.StopContent,
contentId,
value: null,
});
});
if (this._initCanceller.isUsed()) {
return;
}
this._queuedWorkerMessages = [];
log.debug("MTCI: addEventListener prepare buffering worker messages");
const onmessage = (evt: MessageEvent): void => {
const msgData = evt.data as unknown as IWorkerMessage;
const type = msgData.type;
switch (type) {
case WorkerMessageType.LogMessage: {
const formatted = 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 formatWorkerError(l);
default:
assertUnreachable(l);
}
});
switch (msgData.value.logLevel) {
case "NONE":
break;
case "ERROR":
log.error(...formatted);
break;
case "WARNING":
log.warn(...formatted);
break;
case "INFO":
log.info(...formatted);
break;
case "DEBUG":
log.debug(...formatted);
break;
default:
assertUnreachable(msgData.value.logLevel);
}
break;
}
default:
if (this._queuedWorkerMessages !== null) {
this._queuedWorkerMessages.push(evt);
}
break;
}
};
this._settings.worker.addEventListener("message", onmessage);
const onmessageerror = (_msg: MessageEvent) => {
log.error("MTCI: Error when receiving message from worker.");
};
this._settings.worker.addEventListener("messageerror", onmessageerror);
this._initCanceller.signal.register(() => {
log.debug("MTCI: removeEventListener prepare for worker message");
this._settings.worker.removeEventListener("message", onmessage);
this._settings.worker.removeEventListener("messageerror", onmessageerror);
});
// Also bind all `SharedReference` objects:
const throttleVideoBitrate =
adaptiveOptions.throttlers.throttleBitrate.video ?? new SharedReference(Infinity);
bindNumberReferencesToWorker(
worker,
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) => {
sendMessage(worker, {
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;
}
sendMessage(this._settings.worker, {
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;
if (
this._settings.textTrackOptions.textTrackMode === "html" &&
features.htmlTextDisplayer !== null
) {
assert(this._hasTextBufferFeature());
textDisplayer = new features.htmlTextDisplayer(
mediaElement,
this._settings.textTrackOptions.textTrackElement,
);
} else if (features.nativeTextDisplayer !== null) {
assert(this._hasTextBufferFeature());
textDisplayer = new features.nativeTextDisplayer(mediaElement);
} else {
assert(!this._hasTextBufferFeature());
}
this._initCanceller.signal.register(() => {
textDisplayer?.stop();
});
/** 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,
() => reloadMediaSource(0, undefined, undefined),
this._initCanceller.signal,
);
const contentInfo = this._currentContentInfo;
if (contentInfo !== null) {
contentInfo.contentDecryptor = contentDecryptor;
}
const playbackStartParams = {
mediaElement,
textDisplayer,
playbackObserver,
drmInitializationStatus,
mediaSourceStatus,
};
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 },
);
/**
* 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.
*/
const reloadMediaSource = (
deltaPosition: number,
minimumPosition: number | undefined,
maximumPosition: number | undefined,
): void => {
const reloadingContentInfo = this._currentContentInfo;
if (reloadingContentInfo === null) {
log.warn("MTCI: 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,
position,
!isPaused,
);
};
const onmessage = (msg: MessageEvent) => {
const msgData = msg.data as unknown as IWorkerMessage;
switch (msgData.type) {
case WorkerMessageType.AttachMediaSource: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const mediaSourceLink = msgData.value;
mediaSourceStatus.onUpdate(
(currStatus, stopListening) => {
if (currStatus === MediaSourceInitializationStatus.AttachNow) {
stopListening();
log.info("MTCI: 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 WorkerMessageType.Warning:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("warning", formatWorkerError(msgData.value));
break;
case WorkerMessageType.Error:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this._onFatalError(formatWorkerError(msgData.value));
break;
case WorkerMessageType.CreateMediaSource:
this._onCreateMediaSourceMessage(
msgData,
mediaElement,
mediaSourceStatus,
this._settings.worker,
);
break;
case WorkerMessageType.AddSourceBuffer:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
mediaSource.addSourceBuffer(
msgData.value.sourceBufferType,
msgData.value.codec,
);
}
break;
case WorkerMessageType.SourceBufferAppend:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer
.appendBuffer(msgData.value.data, msgData.value.params)
.then((buffered) => {
sendMessage(this._settings.worker, {
type: MainThreadMessageType.SourceBufferSuccess,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value: { buffered },
});
})
.catch((error) => {
sendMessage(this._settings.worker, {
type: MainThreadMessageType.SourceBufferError,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value:
error instanceof CancellationError
? { errorName: "CancellationError" }
: formatSourceBufferError(error).serialize(),
});
});
}
break;
case WorkerMessageType.SourceBufferRemove:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer
.remove(msgData.value.start, msgData.value.end)
.then((buffered) => {
sendMessage(this._settings.worker, {
type: MainThreadMessageType.SourceBufferSuccess,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value: { buffered },
});
})
.catch((error) => {
sendMessage(this._settings.worker, {
type: MainThreadMessageType.SourceBufferError,
mediaSourceId: mediaSource.id,
sourceBufferType: sourceBuffer.type,
operationId: msgData.operationId,
value:
error instanceof CancellationError
? { errorName: "CancellationError" }
: formatSourceBufferError(error).serialize(),
});
});
}
break;
case WorkerMessageType.AbortSourceBuffer:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
const sourceBuffer = arrayFind(
mediaSource.sourceBuffers,
(s) => s.type === msgData.sourceBufferType,
);
if (sourceBuffer === undefined) {
return;
}
sourceBuffer.abort();
}
break;
case WorkerMessageType.UpdateMediaSourceDuration:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
if (mediaSource?.id !== msgData.mediaSourceId) {
return;
}
mediaSource.setDuration(msgData.value.duration, msgData.value.isRealEndKnown);
}
break;
case WorkerMessageType.InterruptMediaSourceDurationUpdate:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
const mediaSource = this._currentContentInfo.mainThreadMediaSource;
if (mediaSource?.id !== msgData.mediaSourceId) {
return;
}
mediaSource.interruptDurationSetting();
}
break;
case WorkerMessageType.EndOfStream:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
this._currentContentInfo.mainThreadMediaSource.maintainEndOfStream();
}
break;
case WorkerMessageType.InterruptEndOfStream:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
this._currentContentInfo.mainThreadMediaSource.stopEndOfStream();
}
break;
case WorkerMessageType.DisposeMediaSource:
{
if (
this._currentContentInfo?.mainThreadMediaSource?.id !==
msgData.mediaSourceId
) {
return;
}
this._currentContentInfo.mainThreadMediaSource.dispose();
}
break;
case WorkerMessageType.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 WorkerMessageType.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 WorkerMessageType.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 WorkerMessageType.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 WorkerMessageType.EncryptionDataEncountered:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
lastContentProtection.setValue(msgData.value);
break;
case WorkerMessageType.ManifestReady: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const manifest = msgData.value.manifest;
this._currentContentInfo.manifest = manifest;
this._updateCodecSupport(manifest);
this._startPlaybackIfReady(playbackStartParams);
break;
}
case WorkerMessageType.ManifestUpdate: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const manifest = this._currentContentInfo?.manifest;
if (isNullOrUndefined(manifest)) {
log.error("MTCI: Manifest update but no Manifest loaded");
return;
}
replicateUpdatesOnManifestMetadata(
manifest,
msgData.value.manifest,
msgData.value.updates,
);
this._currentContentInfo?.streamEventsEmitter?.onManifestUpdate(manifest);
this._updateCodecSupport(manifest);
this.trigger("manifestUpdate", msgData.value.updates);
break;
}
case WorkerMessageType.UpdatePlaybackRate:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
playbackObserver.setPlaybackRate(msgData.value);
break;
case WorkerMessageType.BitrateEstimateChange:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("bitrateEstimateChange", {
type: msgData.value.bufferType,
bitrate: msgData.value.bitrate,
});
break;
case WorkerMessageType.InbandEvent:
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
this.trigger("inbandEvents", msgData.value);
break;
case WorkerMessageType.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 WorkerMessageType.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;
}
sendMessage(this._settings.worker, {
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 },
);
}
sendMessage(this._settings.worker, {
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 WorkerMessageType.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 WorkerMessageType.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("MTCI: Discontinuity's Period not found", msgData.value.periodId);
return;
}
this._currentContentInfo.rebufferingController?.updateDiscontinuityInfo({
period,
bufferType: msgData.value.bufferType,
discontinuity: msgData.value.discontinuity,
position: msgData.value.position,
});
break;
}
case WorkerMessageType.PushTextData: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn("Init: Received AddTextData message but no text displayer exists");
} else {
try {
const ranges = textDisplayer.pushTextData(msgData.value);
sendMessage(this._settings.worker, {
type: MainThreadMessageType.PushTextDataSuccess,
contentId: msgData.contentId,
value: { ranges },
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
sendMessage(this._settings.worker, {
type: MainThreadMessageType.PushTextDataError,
contentId: msgData.contentId,
value: { message },
});
}
}
break;
}
case WorkerMessageType.RemoveTextData: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"Init: Received RemoveTextData message but no text displayer exists",
);
} else {
try {
const ranges = textDisplayer.removeBuffer(
msgData.value.start,
msgData.value.end,
);
sendMessage(this._settings.worker, {
type: MainThreadMessageType.RemoveTextDataSuccess,
contentId: msgData.contentId,
value: { ranges },
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
sendMessage(this._settings.worker, {
type: MainThreadMessageType.RemoveTextDataError,
contentId: msgData.contentId,
value: { message },
});
}
}
break;
}
case WorkerMessageType.ResetTextDisplayer: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"Init: Received ResetTextDisplayer message but no text displayer exists",
);
} else {
textDisplayer.reset();
}
break;
}
case WorkerMessageType.StopTextDisplayer: {
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
if (textDisplayer === null) {
log.warn(
"Init: Received StopTextDisplayer message but no text displayer exists",
);
} else {
textDisplayer.stop();
}
break;
}
case WorkerMessageType.ReloadingMediaSource:
{
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
reloadMediaSource(
msgData.value.timeOffset,
msgData.value.minimumPosition,
msgData.value.maximumPosition,
);
}
break;
case WorkerMessageType.NeedsDecipherabilityFlush:
{
if (this._currentContentInfo?.contentId !== msgData.contentId) {
return;
}
const keySystem = getKeySystemConfiguration(mediaElement);
if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem?.[0])) {
reloadMediaSource(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 WorkerMessageType.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("MTCI: Failed to send segment sink store update");
}
break;
}
case WorkerMessageType.InitSuccess:
case WorkerMessageType.InitError:
// Should already be handled by the API
break;
case WorkerMessageType.LogMessage:
// Already handled by prepare's handler
break;
case WorkerMessageType.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(formatWorkerError(msgData.value.error));
} else {
tObj.resolve(msgData.value.data);
}
} else {
log.error("MTCI: Failed to send segment sink store update");
}
break;
}
default:
assertUnreachable(msgData);
}
};
log.debug("MTCI: addEventListener for worker message");
if (this._queuedWorkerMessages !== null) {
const bufferedMessages = this._queuedWorkerMessages.slice();
log.debug("MTCI: Processing buffered messages", bufferedMessages.length);
for (const message of bufferedMessages) {
onmessage(message);
}
this._queuedWorkerMessages = null;
}
this._settings.worker.addEventListener("message", onmessage);
this._initCanceller.signal.register(() => {
log.debug("MTCI: removeEventListener for worker message");
this._settings.worker.removeEventListener("message", onmessage);
});
}
public dispose(): void {
this._initCanceller.cancel();
if (this._currentContentInfo !== null) {
this._currentContentInfo.mainThreadMediaSource?.dispose();
this._currentContentInfo = null;
}
}
private _onFatalError(err: unknown) {
if (this._initCanceller.isUsed()) {
return;
}
this._initCanceller.cancel();
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);
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 ContentDecryptor = features.decrypt;
if (!ContentDecryptor.hasEmeApis()) {
return createEmeDisabledReference("EME API not available on the current page.");
}
log.debug("MTCI: Creating ContentDecryptor");
const contentDecryptor = new ContentDecryptor(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);
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 {
sendMessage(this._settings.worker, {
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 {
sendMessage(this._settings.worker, {
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(() => {
contentDecryptor.dispose();
});
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 the web worker.
* @param manifest
*/
private _updateCodecSupport(manifest: IManifestMetadata) {
try {
const updatedCodecs = updateManifestCodecSupport(
manifest,
this._currentContentInfo?.contentDecryptor ?? null,
hasMseInWorker,
);
if (updatedCodecs.length > 0) {
sendMessage(this._settings.worker, {
type: MainThreadMessageType.CodecSupportUpdate,
value: updatedCodecs,
});