rx-player
Version:
Canal+ HTML5 Video Player
368 lines (341 loc) • 12.7 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 log from "../../../log";
import type { IManifest } from "../../../manifest";
import { SUPPORTED_ADAPTATIONS_TYPE } from "../../../manifest";
import { StaticRepresentationIndex } from "../../../manifest/classes";
import type { ITrackType } from "../../../public_types";
import idGenerator from "../../../utils/id_generator";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import { getFilenameIndexInUrl } from "../../../utils/url-utils";
import type {
IParsedAdaptation,
IParsedAdaptations,
IParsedManifest,
IParsedPeriod,
IParsedRepresentation,
} from "../types";
import MetaRepresentationIndex from "./representation_index";
export type IParserResponse<T> =
| {
type: "needs-manifest-loader";
value: {
ressources: Array<{ url: string; transportType: string }>;
continue: (loadedRessources: IManifest[]) => IParserResponse<T>;
};
}
| { type: "done"; value: T };
export interface IMetaPlaylistTextTrack {
url: string;
language: string;
closedCaption: boolean;
mimeType: string;
codecs?: string;
}
export interface IMetaPlaylist {
type: "MPL"; // Obligatory token
version: string; // MAJOR.MINOR
dynamic?: boolean; // The MetaPlaylist could need to be updated
pollInterval?: number; // Refresh interval in seconds
contents: Array<{
// Sub-Manifests
url: string; // URL of the Manifest
startTime: number; // start timestamp in seconds
endTime: number; // end timestamp in seconds
transport: string; // "dash" | "smooth" | "metaplaylist"
textTracks?: IMetaPlaylistTextTrack[];
}>;
}
/**
* Parse playlist string to JSON.
* Returns an array of contents.
* @param {string} data
* @param {Object} parserOptions
* @returns {Object}
*/
export default function parseMetaPlaylist(
data: unknown,
parserOptions: {
url?: string | undefined;
serverSyncInfos?:
| {
serverTimestamp: number;
clientTime: number;
}
| undefined;
},
): IParserResponse<IParsedManifest> {
let parsedData;
if (typeof data === "object" && data !== null) {
parsedData = data;
} else if (typeof data === "string") {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsedData = JSON.parse(data);
} catch (_error) {
throw new Error("MPL Parser: Bad MetaPlaylist file. Expected JSON.");
}
} else {
throw new Error(
"MPL Parser: Parser input must be either a string " +
"or the MetaPlaylist data directly.",
);
}
const { contents, version, type } = parsedData as IMetaPlaylist;
if (type !== "MPL") {
throw new Error(
"MPL Parser: Bad MetaPlaylist. " + "The `type` property is not set to `MPL`",
);
}
if (version !== "0.1") {
throw new Error("MPL Parser: Bad MetaPlaylist version");
}
// quick checks
if (isNullOrUndefined(contents) || contents.length === 0) {
throw new Error("MPL Parser: No content found.");
}
const ressources: Array<{ url: string; transportType: string }> = [];
for (const content of contents) {
if (
isNullOrUndefined(content.url) ||
isNullOrUndefined(content.startTime) ||
isNullOrUndefined(content.endTime) ||
isNullOrUndefined(content.transport)
) {
throw new Error("MPL Parser: Malformed content.");
}
ressources.push({ url: content.url, transportType: content.transport });
}
const metaPlaylist: IMetaPlaylist = parsedData as IMetaPlaylist;
return {
type: "needs-manifest-loader",
value: {
ressources,
continue: function parseWholeMPL(loadedRessources: IManifest[]) {
const parsedManifest = createManifest(
metaPlaylist,
loadedRessources,
parserOptions,
);
return { type: "done", value: parsedManifest };
},
},
};
}
/**
* From several parsed manifests, generate a single bigger manifest.
* Each content presents a start and end time, so that periods
* boudaries could be adapted.
* @param {Object} mplData
* @param {Array<Object>} manifests
* @param {Object} parserOptions
* @returns {Object}
*/
function createManifest(
mplData: IMetaPlaylist,
manifests: IManifest[],
parserOptions: {
url?: string | undefined;
serverSyncInfos?: { serverTimestamp: number; clientTime: number } | undefined;
},
): IParsedManifest {
const { url, serverSyncInfos } = parserOptions;
const clockOffset =
serverSyncInfos !== undefined
? serverSyncInfos.serverTimestamp - serverSyncInfos.clientTime
: undefined;
const generateAdaptationID = idGenerator();
const generateRepresentationID = idGenerator();
const { contents } = mplData;
const minimumTime = contents.length > 0 ? contents[0].startTime : 0;
const maximumTime = contents.length > 0 ? contents[contents.length - 1].endTime : 0;
const isDynamic = mplData.dynamic === true;
let firstStart: number | null = null;
let lastEnd: number | null = null;
const periods: IParsedPeriod[] = [];
for (let iMan = 0; iMan < contents.length; iMan++) {
const content = contents[iMan];
firstStart =
firstStart !== null ? Math.min(firstStart, content.startTime) : content.startTime;
lastEnd = lastEnd !== null ? Math.max(lastEnd, content.endTime) : content.endTime;
const currentManifest = manifests[iMan];
if (currentManifest.periods.length <= 0) {
continue;
}
const contentOffset = content.startTime - currentManifest.periods[0].start;
const contentEnd = content.endTime;
const manifestPeriods = [];
for (let iPer = 0; iPer < currentManifest.periods.length; iPer++) {
const currentPeriod = currentManifest.periods[iPer];
const adaptations = SUPPORTED_ADAPTATIONS_TYPE.reduce<IParsedAdaptations>(
(acc, type: ITrackType) => {
const currentAdaptations = currentPeriod.adaptations[type];
if (isNullOrUndefined(currentAdaptations)) {
return acc;
}
const adaptationsForCurrentType: IParsedAdaptation[] = [];
for (let iAda = 0; iAda < currentAdaptations.length; iAda++) {
const currentAdaptation = currentAdaptations[iAda];
const representations: IParsedRepresentation[] = [];
for (let iRep = 0; iRep < currentAdaptation.representations.length; iRep++) {
const currentRepresentation = currentAdaptation.representations[iRep];
const baseContentMetadata = {
isLive: currentManifest.isLive,
manifestPublishTime: currentManifest.publishTime,
periodStart: currentPeriod.start,
periodEnd: currentPeriod.end,
};
const newIndex = new MetaRepresentationIndex(
currentRepresentation.index,
[contentOffset, contentEnd],
content.transport,
baseContentMetadata,
);
let supplementalCodecs: string | undefined;
if (currentRepresentation.codecs.length > 1) {
if (currentRepresentation.codecs.length > 2) {
log.warn(
"metaplaylist",
"MetaPlaylist relying on more than 2 groups of " +
"codecs with retro-compatibility",
);
}
supplementalCodecs = currentRepresentation.codecs[0];
}
const codecs =
currentRepresentation.codecs[currentRepresentation.codecs.length - 1];
representations.push({
bitrate: currentRepresentation.bitrate,
index: newIndex,
cdnMetadata: currentRepresentation.cdnMetadata,
id: currentRepresentation.id,
height: currentRepresentation.height,
width: currentRepresentation.width,
mimeType: currentRepresentation.mimeType,
frameRate: currentRepresentation.frameRate,
codecs,
supplementalCodecs,
isSpatialAudio: currentRepresentation.isSpatialAudio,
contentProtections: currentRepresentation.contentProtections,
});
}
adaptationsForCurrentType.push({
id: currentAdaptation.id,
representations,
type: currentAdaptation.type,
audioDescription: currentAdaptation.isAudioDescription,
closedCaption: currentAdaptation.isClosedCaption,
isDub: currentAdaptation.isDub,
language: currentAdaptation.language,
isSignInterpreted: currentAdaptation.isSignInterpreted,
});
acc[type] = adaptationsForCurrentType;
}
return acc;
},
{},
);
// TODO only first period?
const textTracks: IMetaPlaylistTextTrack[] =
content.textTracks === undefined ? [] : content.textTracks;
const newTextAdaptations: IParsedAdaptation[] = textTracks.map((track) => {
const adaptationID = "gen-text-ada-" + generateAdaptationID();
const representationID = "gen-text-rep-" + generateRepresentationID();
const indexOfFilename = getFilenameIndexInUrl(track.url);
const cdnUrl = track.url.substring(0, indexOfFilename);
const filename = track.url.substring(indexOfFilename);
return {
id: adaptationID,
type: "text",
language: track.language,
closedCaption: track.closedCaption,
manuallyAdded: true,
representations: [
{
bitrate: 0,
cdnMetadata: [{ baseUrl: cdnUrl }],
id: representationID,
mimeType: track.mimeType,
codecs: track.codecs,
index: new StaticRepresentationIndex({ media: filename }),
},
],
};
}, []);
if (newTextAdaptations.length > 0) {
if (isNullOrUndefined(adaptations.text)) {
adaptations.text = newTextAdaptations;
} else {
adaptations.text.push(...newTextAdaptations);
}
}
const newPeriod: IParsedPeriod = {
id: formatId(currentManifest.id) + "_" + formatId(currentPeriod.id),
adaptations,
duration: currentPeriod.duration,
start: contentOffset + currentPeriod.start,
thumbnailTracks: currentPeriod.thumbnailTracks,
};
manifestPeriods.push(newPeriod);
}
for (let i = manifestPeriods.length - 1; i >= 0; i--) {
const period = manifestPeriods[i];
if (period.start >= content.endTime) {
manifestPeriods.splice(i, 1);
} else if (!isNullOrUndefined(period.duration)) {
if (period.start + period.duration > content.endTime) {
period.duration = content.endTime - period.start;
}
} else if (i === manifestPeriods.length - 1) {
period.duration = content.endTime - period.start;
}
}
periods.push(...manifestPeriods);
}
const time = getMonotonicTimeStamp();
const isLastPeriodKnown =
!isDynamic ||
(mplData.pollInterval === undefined &&
(manifests.length <= 0 || manifests[manifests.length - 1].isLastPeriodKnown));
const manifest = {
availabilityStartTime: 0,
clockOffset,
suggestedPresentationDelay: 10,
periods,
transportType: "metaplaylist",
isLive: isDynamic,
isDynamic,
isLastPeriodKnown,
uris: isNullOrUndefined(url) ? [] : [url],
// TODO more precize time bounds?
timeBounds: {
minimumSafePosition: minimumTime,
timeshiftDepth: null,
maximumTimeData: {
isLinear: false,
maximumSafePosition: maximumTime,
livePosition: undefined,
time,
},
},
lifetime: mplData.pollInterval,
};
return manifest;
}
function formatId(str: string) {
return str.replace(/_/g, "_");
}