rx-player
Version:
Canal+ HTML5 Video Player
782 lines (724 loc) • 27.6 kB
text/typescript
/**
* Copyright 2015 CANAL+ Group
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MediaError } from "../../errors";
import log from "../../log";
import { getCodecsWithUnknownSupport } from "../../main_thread/init/utils/update_manifest_codec_support";
import type { IParsedManifest } from "../../parsers/manifest";
import type { ITrackType, IRepresentationFilter } from "../../public_types";
import arrayFind from "../../utils/array_find";
import EventEmitter from "../../utils/event_emitter";
import idGenerator from "../../utils/id_generator";
import warnOnce from "../../utils/warn_once";
import type {
IAdaptationMetadata,
IManifestMetadata,
IPeriodMetadata,
IRepresentationMetadata,
} from "../types";
import { ManifestMetadataFormat } from "../types";
import {
getLivePosition,
getMaximumSafePosition,
getMinimumSafePosition,
getPeriodForTime,
getPeriodAfter,
toTaggedTrack,
} from "../utils";
import type Adaptation from "./adaptation";
import CodecSupportCache from "./codec_support_cache";
import type { ICodecSupportInfo } from "./codec_support_cache";
import type { IManifestAdaptations } from "./period";
import Period from "./period";
import type Representation from "./representation";
import { MANIFEST_UPDATE_TYPE } from "./types";
import type { IPeriodsUpdateResult } from "./update_periods";
import { replacePeriods, updatePeriods } from "./update_periods";
const generateNewManifestId = idGenerator();
/** Options given to the `Manifest` constructor. */
interface IManifestParsingOptions {
/** External callback peforming an automatic filtering of wanted Representations. */
representationFilter?: IRepresentationFilter | undefined;
/** Optional URL that points to a shorter version of the Manifest used
* for updates only. When using this URL for refresh, the manifest will be
* updated with the partial update type. If this URL is undefined, then the
* manifest will be updated fully when it needs to be refreshed, and it will
* fetched through the original URL. */
manifestUpdateUrl?: string | undefined;
}
/** Representation affected by a `decipherabilityUpdate` event. */
export interface IUpdatedRepresentationInfo {
manifest: IManifestMetadata;
period: IPeriodMetadata;
adaptation: IAdaptationMetadata;
representation: IRepresentationMetadata;
}
/** Events emitted by a `Manifest` instance */
export interface IManifestEvents {
/** The Manifest has been updated */
manifestUpdate: IPeriodsUpdateResult;
/** Some Representation's decipherability status has been updated */
decipherabilityUpdate: IUpdatedRepresentationInfo[];
/** Some Representation's support status has been updated */
supportUpdate: null;
/**
* Some `Representation`'s avoidance status has been updated, meaning that we
* might have to avoid playing them due to playback issues.
*/
representationAvoidanceUpdate: IUpdatedRepresentationInfo[];
}
/**
* Normalized Manifest structure.
*
* Details the current content being played:
* - the duration of the content
* - the available tracks
* - the available qualities
* - the segments defined in those qualities
* - ...
* while staying agnostic of the transport protocol used (Smooth, DASH etc.).
*
* The Manifest and its contained information can evolve over time (like when
* updating a dynamic manifest or when right management forbid some tracks from
* being played).
* To perform actions on those changes, any module using this Manifest can
* listen to its sent events and react accordingly.
*
* @class Manifest
*/
export default class Manifest
extends EventEmitter<IManifestEvents>
implements IManifestMetadata
{
public manifestFormat: ManifestMetadataFormat.Class;
/**
* ID uniquely identifying this Manifest.
* No two Manifests should have this ID.
* This ID is automatically calculated each time a `Manifest` instance is
* created.
*/
public readonly id: string;
/**
* Type of transport used by this Manifest (e.g. `"dash"` or `"smooth"`).
*
* TODO This should never be needed as this structure is transport-agnostic.
* But it is specified in the Manifest API. Deprecate?
*/
public transport: string;
/**
* List every Period in that Manifest chronologically (from start to end).
* A Period contains information about the content available for a specific
* period of time.
*/
public readonly periods: Period[];
/**
* When that promise resolves, the whole Manifest needs to be requested again
* so it can be refreshed.
*/
public expired: Promise<void> | null;
/**
* Deprecated. Equivalent to `manifest.periods[0].adaptations`.
* @deprecated
*/
public adaptations: IManifestAdaptations;
/**
* If true, the Manifest can evolve over time:
* New segments can become available in the future, properties of the manifest
* can change...
*/
public isDynamic: boolean;
/**
* If true, this Manifest describes a live content.
* A live content is a specific kind of content where you want to play very
* close to the maximum position (here called the "live edge").
* E.g., a TV channel is a live content.
*/
public isLive: boolean;
/**
* If `true`, no more periods will be added after the current last manifest's
* Period.
* `false` if we know that more Period is coming or if we don't know.
*/
public isLastPeriodKnown: boolean;
/*
* Every URI linking to that Manifest.
* They can be used for refreshing the Manifest.
* Listed from the most important to the least important.
*/
public uris: string[];
/** Optional URL that points to a shorter version of the Manifest used
* for updates only. */
public updateUrl: string | undefined;
/**
* Suggested delay from the "live edge" (i.e. the position corresponding to
* the current broadcast for a live content) the content is suggested to start
* from.
* This only applies to live contents.
*/
public suggestedPresentationDelay: number | undefined;
/**
* Amount of time, in seconds, this Manifest is valid from the time when it
* has been fetched.
* If no lifetime is set, this Manifest does not become invalid after an
* amount of time.
*/
public lifetime: number | undefined;
/**
* Minimum time, in seconds, at which a segment defined in the Manifest
* can begin.
* This is also used as an offset for live content to apply to a segment's
* time.
*/
public availabilityStartTime: number | undefined;
/**
* It specifies the wall-clock time when the manifest was generated and published
* at the origin server. It is present in order to identify different versions
* of manifest instances.
*/
public publishTime: number | undefined;
/*
* Difference between the server's clock in milliseconds and the
* monotonically-raising timestamp used by the RxPlayer.
* This property allows to calculate the server time at any moment.
* `undefined` if we did not obtain the server's time
*/
public clockOffset: number | undefined;
/**
* Data allowing to calculate the minimum and maximum seekable positions at
* any given time.
*/
public timeBounds: {
/**
* This is the theoretical minimum playable position on the content
* regardless of the current Adaptation chosen, as estimated at parsing
* time.
* `undefined` if unknown.
*
* More technically, the `minimumSafePosition` is the maximum between all
* the minimum positions reachable in any of the audio and video Adaptation.
*
* Together with `timeshiftDepth` and the `maximumTimeData` object, this
* value allows to compute at any time the minimum seekable time:
*
* - if `timeshiftDepth` is not set, the minimum seekable time is a
* constant that corresponds to this value.
*
* - if `timeshiftDepth` is set, `minimumSafePosition` will act as the
* absolute minimum seekable time we can never seek below, even when
* `timeshiftDepth` indicates a possible lower position.
* This becomes useful for example when playing live contents which -
* despite having a large window depth - just begun and as such only
* have a few segment available for now.
* Here, `minimumSafePosition` would be the start time of the initial
* segment, and `timeshiftDepth` would be the whole depth that will
* become available once enough segments have been generated.
*/
minimumSafePosition?: number | undefined;
/**
* Some dynamic contents have the concept of a "window depth" (or "buffer
* depth") which allows to set a minimum position for all reachable
* segments, in function of the maximum reachable position.
*
* This is justified by the fact that a server might want to remove older
* segments when new ones become available, to free storage size.
*
* If this value is set to a number, it is the amount of time in seconds
* that needs to be substracted from the current maximum seekable position,
* to obtain the minimum seekable position.
* As such, this value evolves at the same rate than the maximum position
* does (if it does at all).
*
* If set to `null`, this content has no concept of a "window depth".
*/
timeshiftDepth: number | null;
/** Data allowing to calculate the maximum playable position at any given time. */
maximumTimeData: {
/**
* Current position representing live content.
* Only makes sense for un-ended live contents.
*
* `undefined` if unknown or if it doesn't make sense in the current context.
*/
livePosition: number | undefined;
/**
* Whether the maximum positions should evolve linearly over time.
*
* If set to `true`, the maximum seekable position continuously increase at
* the same rate than the time since `time` does.
*/
isLinear: boolean;
/**
* This is the theoretical maximum playable position on the content,
* regardless of the current Adaptation chosen, as estimated at parsing
* time.
*
* More technically, the `maximumSafePosition` is the minimum between all
* attributes indicating the duration of the content in the Manifest.
*
* That is the minimum between:
* - The Manifest original attributes relative to its duration
* - The minimum between all known maximum audio positions
* - The minimum between all known maximum video positions
*
* This can for example be understood as the safe maximum playable
* position through all possible tacks.
*/
maximumSafePosition: number;
/**
* `Monotically-increasing timestamp used by the RxPlayer at the time both
* `maximumSafePosition` and `livePosition` were calculated.
* This can be used to retrieve a new maximum position from them when they
* linearly evolves over time (see `isLinear` property).
*/
time: number;
};
};
/**
* Caches the information if a codec is supported or not in the context of the
* current content.
*/
private _cachedCodecSupport: CodecSupportCache;
/**
* Construct a Manifest instance from a parsed Manifest object (as returned by
* Manifest parsers) and options.
*
* @param {Object} parsedManifest
* @param {Object} options
*/
constructor(parsedManifest: IParsedManifest, options: IManifestParsingOptions) {
super();
const { representationFilter, manifestUpdateUrl } = options;
this.manifestFormat = ManifestMetadataFormat.Class;
this.id = generateNewManifestId();
this.expired = parsedManifest.expired ?? null;
this.transport = parsedManifest.transportType;
this.clockOffset = parsedManifest.clockOffset;
this._cachedCodecSupport = new CodecSupportCache([]);
this.periods = parsedManifest.periods
.map((parsedPeriod) => {
const period = new Period(
parsedPeriod,
this._cachedCodecSupport,
representationFilter,
);
return period;
})
.sort((a, b) => a.start - b.start);
/**
* @deprecated It is here to ensure compatibility with the way the
* v3.x.x manages adaptations at the Manifest level
*/
this.adaptations = this.periods[0] === undefined ? {} : this.periods[0].adaptations;
this.timeBounds = parsedManifest.timeBounds;
this.isDynamic = parsedManifest.isDynamic;
this.isLive = parsedManifest.isLive;
this.isLastPeriodKnown = parsedManifest.isLastPeriodKnown;
this.uris = parsedManifest.uris === undefined ? [] : parsedManifest.uris;
this.updateUrl = manifestUpdateUrl;
this.lifetime = parsedManifest.lifetime;
this.clockOffset = parsedManifest.clockOffset;
this.suggestedPresentationDelay = parsedManifest.suggestedPresentationDelay;
this.availabilityStartTime = parsedManifest.availabilityStartTime;
this.publishTime = parsedManifest.publishTime;
}
/**
* Some environments (e.g. in a WebWorker) may not have the capability to know
* if a mimetype+codec combination is supported on the current platform.
*
* Calling `updateCodecSupport` manually once the codecs supported are known
* by the current environnement allows to work-around this issue.
*
* @param {Array<Object>} [updatedCodecSupportInfo]
* @returns {Error|null} - Refreshing codec support might reveal that some
* `Adaptation` don't have any of their `Representation`s supported.
* In that case, an error object will be created and returned, so you can
* e.g. later emit it as a warning through the RxPlayer API.
*/
public updateCodecSupport(
updatedCodecSupportInfo: ICodecSupportInfo[] = [],
): MediaError | null {
if (updatedCodecSupportInfo.length === 0) {
return null;
}
this._cachedCodecSupport.addCodecs(updatedCodecSupportInfo);
const unsupportedAdaptations: Adaptation[] = [];
for (const period of this.periods) {
period.refreshCodecSupport(unsupportedAdaptations, this._cachedCodecSupport);
}
this.trigger("supportUpdate", null);
if (unsupportedAdaptations.length > 0) {
return new MediaError(
"MANIFEST_INCOMPATIBLE_CODECS_ERROR",
"An Adaptation contains only incompatible codecs.",
{ tracks: unsupportedAdaptations.map(toTaggedTrack) },
);
}
return null;
}
/**
* Returns the Period corresponding to the given `id`.
* Returns `undefined` if there is none.
* @param {string} id
* @returns {Object|undefined}
*/
public getPeriod(id: string): Period | undefined {
return arrayFind(this.periods, (period) => {
return id === period.id;
});
}
/**
* Returns the Period encountered at the given time.
* Returns `undefined` if there is no Period exactly at the given time.
* @param {number} time
* @returns {Object|undefined}
*/
public getPeriodForTime(time: number): Period | undefined {
return getPeriodForTime(this, time);
}
/**
* Returns the first Period starting strictly after the given time.
* Returns `undefined` if there is no Period starting after that time.
* @param {number} time
* @returns {Object|undefined}
*/
public getNextPeriod(time: number): Period | undefined {
return arrayFind(this.periods, (period) => {
return period.start > time;
});
}
/**
* Returns the Period coming chronologically just after another given Period.
* Returns `undefined` if not found.
* @param {Object} period
* @returns {Object|null}
*/
public getPeriodAfter(period: Period): Period | null {
return getPeriodAfter(this, period);
}
/**
* Returns the most important URL from which the Manifest can be refreshed.
* `undefined` if no URL is found.
* @returns {Array.<string>}
*/
public getUrls(): string[] {
return this.uris;
}
/**
* Update the current Manifest properties by giving a new updated version.
* This instance will be updated with the new information coming from it.
* @param {Object} newManifest
*/
public replace(newManifest: Manifest): void {
this._performUpdate(newManifest, MANIFEST_UPDATE_TYPE.Full);
}
/**
* Update the current Manifest properties by giving a new but shorter version
* of it.
* This instance will add the new information coming from it and will
* automatically clean old Periods that shouldn't be available anymore.
*
* /!\ Throws if the given Manifest cannot be used or is not sufficient to
* update the Manifest.
* @param {Object} newManifest
*/
public update(newManifest: Manifest): void {
this._performUpdate(newManifest, MANIFEST_UPDATE_TYPE.Partial);
}
/**
* Returns the theoretical minimum playable position on the content
* regardless of the current Adaptation chosen, as estimated at parsing
* time.
* @returns {number}
*/
public getMinimumSafePosition(): number {
return getMinimumSafePosition(this);
}
/**
* Get the position of the live edge - that is, the position of what is
* currently being broadcasted, in seconds.
* @returns {number|undefined}
*/
public getLivePosition(): number | undefined {
return getLivePosition(this);
}
/**
* Returns the theoretical maximum playable position on the content
* regardless of the current Adaptation chosen, as estimated at parsing
* time.
*/
public getMaximumSafePosition(): number {
return getMaximumSafePosition(this);
}
public updateCodecSupportList(cachedCodecSupport: CodecSupportCache) {
this._cachedCodecSupport = cachedCodecSupport;
}
/**
* Look in the Manifest for Representations linked to the given key ID,
* and mark them as being impossible to decrypt.
* Then trigger a "decipherabilityUpdate" event to notify everyone of the
* changes performed.
* @param {Function} isDecipherableCb
*/
public updateRepresentationsDeciperability(
isDecipherableCb: (content: {
manifest: Manifest;
period: Period;
adaptation: Adaptation;
representation: Representation;
}) => boolean | undefined,
): void {
const updates = updateDeciperability(this, isDecipherableCb);
if (updates.length > 0) {
this.trigger("decipherabilityUpdate", updates);
}
}
/**
* Indicate that some `Representation` needs to be avoided due to playback
* issues.
* @param {Array.<Object>} items
*/
public addRepresentationsToAvoid(
items: Array<{
period: Period;
adaptation: Adaptation;
representation: Representation;
}>,
) {
const updates = [];
for (const item of items) {
const period = this.getPeriod(item.period.id);
if (period === undefined) {
continue;
}
const adaptation = period.getAdaptation(item.adaptation.id);
if (adaptation === undefined) {
continue;
}
const representation = adaptation.getRepresentation(item.representation.id);
if (representation === undefined) {
continue;
}
representation.shouldBeAvoided = true;
updates.push({
manifest: this,
period,
adaptation,
representation,
});
}
if (updates.length > 0) {
this.trigger("representationAvoidanceUpdate", updates);
}
}
/**
* @deprecated only returns adaptations for the first period
* @returns {Array.<Object>}
*/
public getAdaptations(): Adaptation[] {
warnOnce(
"manifest.getAdaptations() is deprecated." +
" Please use manifest.period[].getAdaptations() instead",
);
const firstPeriod = this.periods[0];
if (firstPeriod === undefined) {
return [];
}
const adaptationsByType = firstPeriod.adaptations;
const adaptationsList: Adaptation[] = [];
for (const adaptationType in adaptationsByType) {
if (Object.prototype.hasOwnProperty.call(adaptationsByType, adaptationType)) {
const adaptations = adaptationsByType[
adaptationType as ITrackType
] as Adaptation[];
adaptationsList.push(...adaptations);
}
}
return adaptationsList;
}
/**
* @deprecated only returns adaptations for the first period
* @returns {Array.<Object>}
*/
public getAdaptationsForType(adaptationType: ITrackType): Adaptation[] {
warnOnce(
"manifest.getAdaptationsForType(type) is deprecated." +
" Please use manifest.period[].getAdaptationsForType(type) instead",
);
const firstPeriod = this.periods[0];
if (firstPeriod === undefined) {
return [];
}
const adaptationsForType = firstPeriod.adaptations[adaptationType];
return adaptationsForType === undefined ? [] : adaptationsForType;
}
/**
* @deprecated only returns adaptations for the first period
* @returns {Array.<Object>}
*/
public getAdaptation(wantedId: number | string): Adaptation | undefined {
warnOnce(
"manifest.getAdaptation(id) is deprecated." +
" Please use manifest.period[].getAdaptation(id) instead",
);
return arrayFind(this.getAdaptations(), ({ id }) => wantedId === id);
}
/**
* Format the current `Manifest`'s properties into a
* `IManifestMetadata` format which can better be communicated through
* another thread.
*
* Please bear in mind however that the returned object will not be updated
* when the current `Manifest` instance is updated, it is only a
* snapshot at the current time.
*
* If you want to keep that data up-to-date with the current `Manifest`
* instance, you will have to do it yourself.
*
* @returns {Object}
*/
public getMetadataSnapshot(): IManifestMetadata {
const periods: IPeriodMetadata[] = [];
for (const period of this.periods) {
periods.push(period.getMetadataSnapshot());
}
return {
manifestFormat: ManifestMetadataFormat.MetadataObject,
id: this.id,
periods,
isDynamic: this.isDynamic,
isLive: this.isLive,
isLastPeriodKnown: this.isLastPeriodKnown,
suggestedPresentationDelay: this.suggestedPresentationDelay,
clockOffset: this.clockOffset,
uris: this.uris,
availabilityStartTime: this.availabilityStartTime,
timeBounds: this.timeBounds,
};
}
/**
* Returns a list of all codecs that the support is not known yet.
* If a representation with (`isSupported`) is undefined, we consider the
* codec support as unknown.
*
* This function iterates through all periods, adaptations, and representations,
* and collects unknown codecs.
*
* @returns {Array} The list of codecs with unknown support status.
*/
public getCodecsWithUnknownSupport(): Array<{ mimeType: string; codec: string }> {
return getCodecsWithUnknownSupport(this);
}
/**
* @param {Object} newManifest
* @param {number} updateType
*/
private _performUpdate(newManifest: Manifest, updateType: MANIFEST_UPDATE_TYPE): void {
this.availabilityStartTime = newManifest.availabilityStartTime;
this.expired = newManifest.expired;
this.isDynamic = newManifest.isDynamic;
this.isLive = newManifest.isLive;
this.isLastPeriodKnown = newManifest.isLastPeriodKnown;
this.lifetime = newManifest.lifetime;
this.clockOffset = newManifest.clockOffset;
this.suggestedPresentationDelay = newManifest.suggestedPresentationDelay;
this.transport = newManifest.transport;
this.publishTime = newManifest.publishTime;
let updatedPeriodsResult;
if (updateType === MANIFEST_UPDATE_TYPE.Full) {
this.timeBounds = newManifest.timeBounds;
this.uris = newManifest.uris;
updatedPeriodsResult = replacePeriods(this.periods, newManifest.periods);
} else {
this.timeBounds.maximumTimeData = newManifest.timeBounds.maximumTimeData;
this.updateUrl = newManifest.uris[0];
updatedPeriodsResult = updatePeriods(this.periods, newManifest.periods);
// Partial updates do not remove old Periods.
// This can become a memory problem when playing a content long enough.
// Let's clean manually Periods behind the minimum possible position.
const min = this.getMinimumSafePosition();
while (this.periods.length > 0) {
const period = this.periods[0];
if (period.end === undefined || period.end > min) {
break;
}
this.periods.shift();
}
}
this.updateCodecSupport();
// Re-set this.adaptations for retro-compatibility in v3.x.x
this.adaptations = this.periods[0] === undefined ? {} : this.periods[0].adaptations;
// Let's trigger events at the end, as those can trigger side-effects.
// We do not want the current Manifest object to be incomplete when those
// happen.
this.trigger("manifestUpdate", updatedPeriodsResult);
}
}
/**
* Update `decipherable` property of every `Representation` found in the
* Manifest based on the result of a `isDecipherable` callback:
* - When that callback returns `true`, update `decipherable` to `true`
* - When that callback returns `false`, update `decipherable` to `false`
* - When that callback returns `undefined`, update `decipherable` to
* `undefined`
* @param {Manifest} manifest
* @param {Function} isDecipherable
* @returns {Array.<Object>}
*/
function updateDeciperability(
manifest: Manifest,
isDecipherable: (content: {
manifest: Manifest;
period: Period;
adaptation: Adaptation;
representation: Representation;
}) => boolean | undefined,
): IUpdatedRepresentationInfo[] {
const updates: IUpdatedRepresentationInfo[] = [];
for (const period of manifest.periods) {
for (const adaptation of period.getAdaptations()) {
let hasOnlyUndecipherableRepresentations = true;
for (const representation of adaptation.representations) {
const content = { manifest, period, adaptation, representation };
const result = isDecipherable(content);
if (result !== false) {
hasOnlyUndecipherableRepresentations = false;
}
if (result !== representation.decipherable) {
updates.push(content);
representation.decipherable = result;
if (result === true) {
adaptation.supportStatus.isDecipherable = true;
} else if (
result === undefined &&
adaptation.supportStatus.isDecipherable === false
) {
adaptation.supportStatus.isDecipherable = undefined;
}
log.debug(
"manifest",
`Decipherability changed for "${representation.id}"`,
`(${representation.bitrate})`,
String(representation.decipherable),
);
}
}
if (hasOnlyUndecipherableRepresentations) {
adaptation.supportStatus.isDecipherable = false;
}
}
}
return updates;
}
export type { IManifestParsingOptions };