UNPKG

rx-player

Version:
759 lines (721 loc) 25.5 kB
import type { IProcessedProtectionData } from "../main_thread/types"; import type { IManifest, IPeriod, IAdaptation, IPeriodsUpdateResult } from "../manifest"; import type { IAudioRepresentation, IAudioTrack, IRepresentationFilter, ITextTrack, ITrackType, IVideoRepresentation, IVideoTrack, } from "../public_types"; import areArraysOfNumbersEqual from "../utils/are_arrays_of_numbers_equal"; import arrayFind from "../utils/array_find"; import isNullOrUndefined from "../utils/is_null_or_undefined"; import getMonotonicTimeStamp from "../utils/monotonic_timestamp"; import { objectValues } from "../utils/object_values"; import type { IAdaptationMetadata, IManifestMetadata, IPeriodMetadata, IRepresentationMetadata, IThumbnailTrackMetadata, } from "./types"; /** List in an array every possible value for the Adaptation's `type` property. */ export const SUPPORTED_ADAPTATIONS_TYPE: ITrackType[] = ["audio", "video", "text"]; /** * Returns the theoretical minimum playable position on the content * regardless of the current Adaptation chosen, as estimated at parsing * time. * @param {Object} manifest * @returns {number} */ export function getMinimumSafePosition(manifest: IManifestMetadata): number { const windowData = manifest.timeBounds; if (windowData.timeshiftDepth === null) { return windowData.minimumSafePosition ?? 0; } const { maximumTimeData } = windowData; let maximumTime: number; if (!windowData.maximumTimeData.isLinear) { maximumTime = maximumTimeData.maximumSafePosition; } else { const timeDiff = getMonotonicTimeStamp() - maximumTimeData.time; maximumTime = maximumTimeData.maximumSafePosition + timeDiff / 1000; } const theoricalMinimum = maximumTime - windowData.timeshiftDepth; return Math.max(windowData.minimumSafePosition ?? 0, theoricalMinimum); } /** * Get the position of the live edge - that is, the position of what is * currently being broadcasted, in seconds. * @param {Object} manifest * @returns {number|undefined} */ export function getLivePosition(manifest: IManifestMetadata): number | undefined { const { maximumTimeData } = manifest.timeBounds; if (!manifest.isLive || maximumTimeData.livePosition === undefined) { return undefined; } if (!maximumTimeData.isLinear) { return maximumTimeData.livePosition; } const timeDiff = getMonotonicTimeStamp() - maximumTimeData.time; return maximumTimeData.livePosition + timeDiff / 1000; } /** * Returns the theoretical maximum playable position on the content * regardless of the current Adaptation chosen, as estimated at parsing * time. * @param {Object} manifest * @returns {number} */ export function getMaximumSafePosition(manifest: IManifestMetadata): number { const { maximumTimeData } = manifest.timeBounds; if (!maximumTimeData.isLinear) { return maximumTimeData.maximumSafePosition; } const timeDiff = getMonotonicTimeStamp() - maximumTimeData.time; return maximumTimeData.maximumSafePosition + timeDiff / 1000; } /** * Returns Adaptations that contain supported Representation(s). * @param {string|undefined} type - If set filter on a specific Adaptation's * type. Will return for all types if `undefined`. * @returns {Array.<Adaptation>} */ export function getSupportedAdaptations( period: IPeriod, type?: ITrackType | undefined, ): IAdaptation[]; export function getSupportedAdaptations( period: IPeriodMetadata, type?: ITrackType | undefined, ): IAdaptationMetadata[]; export function getSupportedAdaptations( period: IPeriod | IPeriodMetadata, type?: ITrackType | undefined, ): IAdaptationMetadata[] | IAdaptation[] { if (type === undefined) { return getAdaptations(period).filter((ada) => { return ( ada.supportStatus.hasSupportedCodec !== false && ada.supportStatus.isDecipherable !== false ); }); } const adaptationsForType = period.adaptations[type]; if (adaptationsForType === undefined) { return []; } return adaptationsForType.filter((ada) => { return ( ada.supportStatus.hasSupportedCodec !== false && ada.supportStatus.isDecipherable !== false ); }); } /** * Returns the Period encountered at the given time. * Returns `undefined` if there is no Period exactly at the given time. * @param {Object} manifest * @param {number} time * @returns {Object|undefined} */ export function getPeriodForTime(manifest: IManifest, time: number): IPeriod | undefined; export function getPeriodForTime( manifest: IManifestMetadata, time: number, ): IPeriodMetadata | undefined; export function getPeriodForTime( manifest: IManifestMetadata | IManifest, time: number, ): IPeriod | IPeriodMetadata | undefined { let nextPeriod = null; for (let i = manifest.periods.length - 1; i >= 0; i--) { const period = manifest.periods[i]; if (periodContainsTime(period, time, nextPeriod)) { return period; } nextPeriod = period; } } /** * Returns the Period coming chronologically just after another given Period. * Returns `undefined` if not found. * @param {Object} manifest * @param {Object} period * @returns {Object|null} */ export function getPeriodAfter(manifest: IManifest, period: IPeriod): IPeriod | null; export function getPeriodAfter( manifest: IManifestMetadata, period: IPeriodMetadata, ): IPeriodMetadata | null; export function getPeriodAfter( manifest: IManifestMetadata | IManifest, period: IPeriodMetadata | IPeriod, ): IPeriod | IPeriodMetadata | null { const endOfPeriod = period.end; if (endOfPeriod === undefined) { return null; } const nextPeriod = arrayFind(manifest.periods, (_period) => { return _period.end === undefined || endOfPeriod < _period.end; }); return nextPeriod === undefined ? null : nextPeriod; } /** * Returns true if the give time is in the time boundaries of this `Period`. * @param {Object} period - The `Period` which we want to check. * @param {number} time * @param {object|null} nextPeriod - Period coming chronologically just * after in the same Manifest. `null` if this instance is the last `Period`. * @returns {boolean} */ export function periodContainsTime( period: IPeriodMetadata, time: number, nextPeriod: IPeriodMetadata | null, ): boolean { if (time >= period.start && (period.end === undefined || time < period.end)) { return true; } else if ( time === period.end && (nextPeriod === null || nextPeriod.start > period.end) ) { // The last possible timed position of a Period is ambiguous as it is // frequently in common with the start of the next one: is it part of // the current or of the next Period? // Here we only consider it part of the current Period if it is the // only one with that position. return true; } return false; } /** * Returns every `Adaptations` (or `tracks`) linked to that Period, in an * Array. * @returns {Array.<Object>} */ export function getAdaptations(period: IPeriod): IAdaptation[]; export function getAdaptations(period: IPeriodMetadata): IAdaptationMetadata[]; export function getAdaptations( period: IPeriodMetadata | IPeriod, ): IAdaptationMetadata[] | IAdaptation[] { const adaptationsByType = period.adaptations; return objectValues(adaptationsByType).reduce<IAdaptationMetadata[]>( // Note: the second case cannot happen. TS is just being dumb here (acc, adaptations) => !isNullOrUndefined(adaptations) ? acc.concat(adaptations) : acc, [], ); } /** * Format an `Adaptation`, generally of type `"audio"`, as an `IAudioTrack`. * @param {Object} adaptation * @param {boolean} filterPlayable - If `true` only "playable" Representation * will be returned. * @returns {Object} */ export function toAudioTrack( adaptation: IAdaptationMetadata, filterPlayable: boolean, ): IAudioTrack { const formatted: IAudioTrack = { language: adaptation.language ?? "", normalized: adaptation.normalizedLanguage ?? "", audioDescription: adaptation.isAudioDescription === true, id: adaptation.id, representations: (filterPlayable ? adaptation.representations.filter((r) => isRepresentationPlayable(r) === true) : adaptation.representations ).map(toAudioRepresentation), label: adaptation.label, }; if (adaptation.isDub === true) { formatted.dub = true; } return formatted; } /** * Format an `Adaptation`, generally of type `"audio"`, as an `IAudioTrack`. * @param {Object} adaptation * @returns {Object} */ export function toTextTrack(adaptation: IAdaptationMetadata): ITextTrack { return { language: adaptation.language ?? "", normalized: adaptation.normalizedLanguage ?? "", closedCaption: adaptation.isClosedCaption === true, id: adaptation.id, label: adaptation.label, forced: adaptation.isForcedSubtitles, }; } /** * Format an `Adaptation`, generally of type `"video"`, as an `IAudioTrack`. * @param {Object} adaptation * @param {boolean} filterPlayable - If `true` only "playable" Representation * will be returned. * @returns {Object} */ export function toVideoTrack( adaptation: IAdaptationMetadata, filterPlayable: boolean, ): IVideoTrack { const trickModeTracks = adaptation.trickModeTracks !== undefined ? adaptation.trickModeTracks.map((trickModeAdaptation) => { const representations = ( filterPlayable ? trickModeAdaptation.representations.filter( (r) => isRepresentationPlayable(r) === true, ) : trickModeAdaptation.representations ).map(toVideoRepresentation); const trickMode: IVideoTrack = { id: trickModeAdaptation.id, representations, isTrickModeTrack: true, }; if (trickModeAdaptation.isSignInterpreted === true) { trickMode.signInterpreted = true; } return trickMode; }) : undefined; const videoTrack: IVideoTrack = { id: adaptation.id, representations: (filterPlayable ? adaptation.representations.filter((r) => isRepresentationPlayable(r) === true) : adaptation.representations ).map(toVideoRepresentation), label: adaptation.label, }; if (adaptation.isSignInterpreted === true) { videoTrack.signInterpreted = true; } if (adaptation.isTrickModeTrack === true) { videoTrack.isTrickModeTrack = true; } if (trickModeTracks !== undefined) { videoTrack.trickModeTracks = trickModeTracks; } return videoTrack; } /** * Format Representation as an `IAudioRepresentation`. * @returns {Object} */ export function toAudioRepresentation( representation: IRepresentationMetadata, ): IAudioRepresentation { const { id, bitrate, codecs, isSpatialAudio, isSupported, decipherable } = representation; return { id, bitrate, codec: codecs?.[0], isSpatialAudio, isCodecSupported: isSupported, decipherable, }; } /** * Format Representation as an `IVideoRepresentation`. * @returns {Object} */ export function toVideoRepresentation( representation: IRepresentationMetadata, ): IVideoRepresentation { const { id, bitrate, frameRate, width, height, codecs, hdrInfo, isSupported, decipherable, contentProtections, } = representation; return { id, bitrate, frameRate, width, height, codec: codecs?.[0], hdrInfo, isCodecSupported: isSupported, decipherable, contentProtections: contentProtections !== undefined ? { keyIds: contentProtections.keyIds, } : undefined, }; } export function toTaggedTrack(adaptation: IAdaptation): ITaggedTrack { switch (adaptation.type) { case "audio": return { type: "audio", track: toAudioTrack(adaptation, false) }; case "video": return { type: "video", track: toVideoTrack(adaptation, false) }; case "text": return { type: "text", track: toTextTrack(adaptation) }; } } /** * Returns `true` if the `Representation` has a high chance of being playable on * the current device (its codec seems supported and we don't consider it to be * un-decipherable). * * Returns `false` if the `Representation` has a high chance of being unplayable * on the current device (its codec seems unsupported and/or we consider it to * be un-decipherable). * * Returns `undefined` if we don't know as the codec has not been checked yet. * * @param {Object} representation * @returns {boolean|undefined} */ export function isRepresentationPlayable( representation: IRepresentationMetadata, ): boolean | undefined { if (representation.decipherable === false) { return false; } return representation.isSupported; } /** * Information on a Representation affected by a `decipherabilityUpdates` event. */ export interface IDecipherabilityStatusChangedElement { manifest: IManifestMetadata; period: IPeriodMetadata; adaptation: IAdaptationMetadata; representation: IRepresentationMetadata; } /** * Change the decipherability of Representations which have their key id in one * of the given Arrays: * * - Those who have a key id listed in `whitelistedKeyIds` will have their * decipherability updated to `true` * * - Those who have a key id listed in `blacklistedKeyIds` will have their * decipherability updated to `false` * * - Those who have a key id listed in `delistedKeyIds` will have their * decipherability updated to `undefined`. * * @param {Object} manifest * @param {Object} updates * @param {Array.<Uint8Array>} updates.whitelistedKeyIds * @param {Array.<Uint8Array>} updates.blacklistedKeyIds * @param {Array.<Uint8Array>} updates.delistedKeyIds */ export function updateDecipherabilityFromKeyIds( manifest: IManifestMetadata, updates: { whitelistedKeyIds: Uint8Array[]; blacklistedKeyIds: Uint8Array[]; delistedKeyIds: Uint8Array[]; }, ): IDecipherabilityStatusChangedElement[] { const { whitelistedKeyIds, blacklistedKeyIds, delistedKeyIds } = updates; return updateRepresentationsDeciperability(manifest, (representation) => { if (representation.contentProtections === undefined) { return representation.decipherable; } const contentKIDs = representation.contentProtections.keyIds; if (contentKIDs !== undefined) { for (const elt of contentKIDs) { for (const blacklistedKeyId of blacklistedKeyIds) { if (areArraysOfNumbersEqual(blacklistedKeyId, elt)) { return false; } } for (const whitelistedKeyId of whitelistedKeyIds) { if (areArraysOfNumbersEqual(whitelistedKeyId, elt)) { return true; } } for (const delistedKeyId of delistedKeyIds) { if (areArraysOfNumbersEqual(delistedKeyId, elt)) { return undefined; } } } } return representation.decipherable; }); } /** * Update decipherability to `false` to any Representation which is linked to * the given initialization data. * @param {Object} manifest * @param {Object} initData */ export function updateDecipherabilityFromProtectionData( manifest: IManifestMetadata, initData: IProcessedProtectionData, ): IDecipherabilityStatusChangedElement[] { return updateRepresentationsDeciperability(manifest, (representation) => { if (representation.decipherable === false) { return false; } const segmentProtections = representation.contentProtections?.initData ?? []; for (const protection of segmentProtections) { if (initData.type === undefined || protection.type === initData.type) { const containedInitData = initData.values .getFormattedValues() .every((undecipherableVal) => { return protection.values.some((currVal) => { return ( (undecipherableVal.systemId === undefined || currVal.systemId === undecipherableVal.systemId) && areArraysOfNumbersEqual(currVal.data, undecipherableVal.data) ); }); }); if (containedInitData) { return false; } } } return representation.decipherable; }); } /** * 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 updateRepresentationsDeciperability( manifest: IManifestMetadata, isDecipherable: (rep: IRepresentationMetadata) => boolean | undefined, ): IDecipherabilityStatusChangedElement[] { const updates: IDecipherabilityStatusChangedElement[] = []; for (const period of manifest.periods) { const adaptationsByType = period.adaptations; const adaptations = objectValues(adaptationsByType).reduce<IAdaptationMetadata[]>( // Note: the second case cannot happen. TS is just being dumb here (acc, adaps) => (!isNullOrUndefined(adaps) ? acc.concat(adaps) : acc), [], ); for (const adaptation of adaptations) { let hasOnlyUndecipherableRepresentations = true; for (const representation of adaptation.representations) { const result = isDecipherable(representation); if (result !== false) { hasOnlyUndecipherableRepresentations = false; } if (result !== representation.decipherable) { if (result === true) { adaptation.supportStatus.isDecipherable = true; } else if ( result === undefined && adaptation.supportStatus.isDecipherable === false ) { adaptation.supportStatus.isDecipherable = undefined; } updates.push({ manifest, period, adaptation, representation }); representation.decipherable = result; } } if (hasOnlyUndecipherableRepresentations) { adaptation.supportStatus.isDecipherable = false; } } } return updates; } /** * * TODO that function is kind of very ugly, yet should work. * Maybe find out a better system for Manifest updates. * @param {Object} baseManifest * @param {Object} newManifest * @param {Array.<Object>} updates */ export function replicateUpdatesOnManifestMetadata( baseManifest: IManifestMetadata, newManifest: Omit<IManifestMetadata, "periods">, updates: IPeriodsUpdateResult, ) { for (const prop of Object.keys(newManifest)) { if (prop !== "periods") { // trust me bro // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (baseManifest as any)[prop] = (newManifest as any)[prop]; } } for (const removedPeriod of updates.removedPeriods) { for (let periodIdx = 0; periodIdx < baseManifest.periods.length; periodIdx++) { if (baseManifest.periods[periodIdx].id === removedPeriod.id) { baseManifest.periods.splice(periodIdx, 1); break; } } } for (const updatedPeriod of updates.updatedPeriods) { for (let periodIdx = 0; periodIdx < baseManifest.periods.length; periodIdx++) { const newPeriod = updatedPeriod.period; if (baseManifest.periods[periodIdx].id === updatedPeriod.period.id) { const basePeriod = baseManifest.periods[periodIdx]; for (const prop of Object.keys(newPeriod)) { if (prop !== "adaptations") { // trust me bro // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (basePeriod as any)[prop] = (newPeriod as any)[prop]; } } for (const removedThumbnailTrack of updatedPeriod.result.removedThumbnailTracks) { for ( let thumbIdx = 0; thumbIdx < basePeriod.thumbnailTracks.length; thumbIdx++ ) { if (basePeriod.thumbnailTracks[thumbIdx].id === removedThumbnailTrack.id) { basePeriod.thumbnailTracks.splice(thumbIdx, 1); break; } } } for (const updatedThumbnailTrack of updatedPeriod.result.updatedThumbnailTracks) { const newThumbnailTrack = updatedThumbnailTrack; for ( let thumbIdx = 0; thumbIdx < basePeriod.thumbnailTracks.length; thumbIdx++ ) { if (basePeriod.thumbnailTracks[thumbIdx].id === newThumbnailTrack.id) { const baseThumbnailTrack = basePeriod.thumbnailTracks[thumbIdx]; for (const prop of Object.keys(newThumbnailTrack) as Array< keyof IThumbnailTrackMetadata >) { // eslint-disable-next-line (baseThumbnailTrack as any)[prop] = newThumbnailTrack[prop]; } break; } } } for (const addedThumbnailTrack of updatedPeriod.result.addedThumbnailTracks) { basePeriod.thumbnailTracks.push(addedThumbnailTrack); } for (const removedAdaptation of updatedPeriod.result.removedAdaptations) { const ttype = removedAdaptation.trackType; const adaptationsForType = basePeriod.adaptations[ttype] ?? []; for (let adapIdx = 0; adapIdx < adaptationsForType.length; adapIdx++) { if (adaptationsForType[adapIdx].id === removedAdaptation.id) { adaptationsForType.splice(adapIdx, 1); break; } } } for (const updatedAdaptation of updatedPeriod.result.updatedAdaptations) { const newAdaptation = updatedAdaptation.adaptation; const ttype = updatedAdaptation.trackType; const adaptationsForType = basePeriod.adaptations[ttype] ?? []; for (let adapIdx = 0; adapIdx < adaptationsForType.length; adapIdx++) { if (adaptationsForType[adapIdx].id === newAdaptation) { const baseAdaptation = adaptationsForType[adapIdx]; for (const removedRepresentation of updatedAdaptation.removedRepresentations) { for ( let repIdx = 0; repIdx < baseAdaptation.representations.length; repIdx++ ) { if ( baseAdaptation.representations[repIdx].id === removedRepresentation ) { baseAdaptation.representations.splice(repIdx, 1); break; } } } for (const newRepresentation of updatedAdaptation.updatedRepresentations) { for ( let repIdx = 0; repIdx < baseAdaptation.representations.length; repIdx++ ) { if ( baseAdaptation.representations[repIdx].id === newRepresentation.id ) { const baseRepresentation = baseAdaptation.representations[repIdx]; for (const prop of Object.keys(newRepresentation) as Array< keyof IRepresentationMetadata >) { if (prop !== "decipherable") { // eslint-disable-next-line (baseRepresentation as any)[prop] = newRepresentation[prop]; } } break; } } } for (const addedRepresentation of updatedAdaptation.addedRepresentations) { baseAdaptation.representations.push(addedRepresentation); } break; } } } for (const addedAdaptation of updatedPeriod.result.addedAdaptations) { const ttype = addedAdaptation.type; const adaptationsForType = basePeriod.adaptations[ttype]; if (adaptationsForType === undefined) { basePeriod.adaptations[ttype] = [addedAdaptation]; } else { adaptationsForType.push(addedAdaptation); } } break; } } } for (const addedPeriod of updates.addedPeriods) { for (let periodIdx = 0; periodIdx < baseManifest.periods.length; periodIdx++) { if (baseManifest.periods[periodIdx].start > addedPeriod.start) { baseManifest.periods.splice(periodIdx, 0, addedPeriod); break; } } baseManifest.periods.push(addedPeriod); } } export function createRepresentationFilterFromFnString( fnString: string, ): IRepresentationFilter { // eslint-disable-next-line @typescript-eslint/no-implied-eval return new Function( `return (${fnString}(arguments[0], arguments[1]))`, ) as IRepresentationFilter; } interface ITaggedAudioTrack { type: "audio"; track: IAudioTrack; } interface ITaggedVideoTrack { type: "video"; track: IVideoTrack; } interface ITaggedTextTrack { type: "text"; track: ITextTrack; } export type ITaggedTrack = ITaggedAudioTrack | ITaggedVideoTrack | ITaggedTextTrack;