UNPKG

rx-player

Version:
657 lines (614 loc) 23.1 kB
/** * 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 type { IStreamOrchestratorPlaybackObservation, IBufferType, } from "../../../core/types"; import { MediaError } from "../../../errors"; import type { IManifest, IAdaptation, IRepresentationIndex, IPeriod, } from "../../../manifest"; import type { IReadOnlyPlaybackObserver } from "../../../playback_observer"; import type { IPlayerError } from "../../../public_types"; import EventEmitter from "../../../utils/event_emitter"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import SortedList from "../../../utils/sorted_list"; import TaskCanceller from "../../../utils/task_canceller"; /** * Observes what's being played and take care of media events relating to time * boundaries: * - Emits a `endingPositionChange` when the known maximum playable position * of the current content is known and every time it changes. * - Emits `endOfStream` API once segments have been pushed until the end and * `resumeStream` if downloads starts back. * - Emits a `periodChange` event when the currently-playing Period seemed to * have changed. * - emit "warning" events when what is being played is outside of the * Manifest range. * @class ContentTimeBoundariesObserver */ export default class ContentTimeBoundariesObserver extends EventEmitter<IContentTimeBoundariesObserverEvent> { /** Allows to interrupt everything the `ContentTimeBoundariesObserver` is doing. */ private _canceller: TaskCanceller; /** Store information on every created "Streams". */ private _activeStreams: Map<IBufferType, IActiveStreamsInfo>; /** The `Manifest` object linked to the current content. */ private _manifest: IManifest; /** Allows to calculate at any time maximum positions of the content */ private _maximumPositionCalculator: MaximumPositionCalculator; /** Enumerate all possible buffer types in the current content. */ private _allBufferTypes: IBufferType[]; /** * Stores the `id` property of the last Period for which a `periodChange` * event has been sent. * Allows to avoid multiple times in a row `periodChange` for the same * Period. */ private _lastCurrentPeriodId: string | null; /** * @param {Object} manifest * @param {Object} playbackObserver */ constructor( manifest: IManifest, playbackObserver: IReadOnlyPlaybackObserver<IStreamOrchestratorPlaybackObservation>, bufferTypes: IBufferType[], ) { super(); this._canceller = new TaskCanceller(); this._manifest = manifest; this._activeStreams = new Map(); this._allBufferTypes = bufferTypes; this._lastCurrentPeriodId = null; /** * Allows to calculate the minimum and maximum playable position on the * whole content. */ const maximumPositionCalculator = new MaximumPositionCalculator(manifest); this._maximumPositionCalculator = maximumPositionCalculator; const cancelSignal = this._canceller.signal; playbackObserver.listen( ({ position }) => { const wantedPosition = position.getWanted(); if (wantedPosition < manifest.getMinimumSafePosition()) { const warning = new MediaError( "MEDIA_TIME_BEFORE_MANIFEST", "The current position is behind the " + "earliest time announced in the Manifest.", ); this.trigger("warning", warning); } else if ( wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition() ) { const warning = new MediaError( "MEDIA_TIME_AFTER_MANIFEST", "The current position is after the latest " + "time announced in the Manifest.", ); this.trigger("warning", warning); } }, { includeLastObservation: true, clearSignal: cancelSignal }, ); manifest.addEventListener( "manifestUpdate", () => { this.trigger("endingPositionChange", this._getManifestEndTime()); if (cancelSignal.isCancelled()) { return; } this._checkEndOfStream(); }, cancelSignal, ); } /** * Returns an estimate of the current last position which may be played in * the content at the moment. * @returns {Object} */ public getCurrentEndingTime(): IEndingPositionInformation { return this._getManifestEndTime(); } /** * Method to call any time an Adaptation has been selected. * * That Adaptation switch will be considered as active until the * `onPeriodCleared` method has been called for the same `bufferType` and * `Period`, or until `dispose` is called. * @param {string} bufferType - The type of buffer concerned by the Adaptation * switch * @param {Object} period - The Period concerned by the Adaptation switch * @param {Object|null} adaptation - The Adaptation selected. `null` if the * absence of `Adaptation` has been explicitely selected for this Period and * buffer type (e.g. no video). */ public onAdaptationChange( bufferType: IBufferType, period: IPeriod, adaptation: IAdaptation | null, ): void { if (this._manifest.isLastPeriodKnown) { const lastPeriod = this._manifest.periods[this._manifest.periods.length - 1]; if (period.id === lastPeriod?.id) { if (bufferType === "audio" || bufferType === "video") { if (bufferType === "audio") { this._maximumPositionCalculator.updateLastAudioAdaptation(adaptation); } else { this._maximumPositionCalculator.updateLastVideoAdaptation(adaptation); } const endingPosition = this._maximumPositionCalculator.getEndingPosition(); const newEndingPosition = endingPosition !== undefined ? { isEnd: true, endingPosition } : { isEnd: false, endingPosition: this._maximumPositionCalculator.getMaximumAvailablePosition(), }; this.trigger("endingPositionChange", newEndingPosition); } } } if (this._canceller.isUsed()) { return; } if (adaptation === null) { this._addActivelyLoadedPeriod(period, bufferType); } } /** * Method to call any time a Representation has been selected. * * That Representation switch will be considered as active until the * `onPeriodCleared` method has been called for the same `bufferType` and * `Period`, or until `dispose` is called. * @param {string} bufferType - The type of buffer concerned by the * Representation switch * @param {Object} period - The Period concerned by the Representation switch */ public onRepresentationChange(bufferType: IBufferType, period: IPeriod): void { this._addActivelyLoadedPeriod(period, bufferType); } /** * Method to call any time a Period and type combination is not considered * anymore. * * Calling this method allows to signal that a previous Adaptation and/or * Representation change respectively indicated by an `onAdaptationChange` and * an `onRepresentationChange` call, are not active anymore. * @param {string} bufferType - The type of buffer concerned * @param {Object} period - The Period concerned */ public onPeriodCleared(bufferType: IBufferType, period: IPeriod): void { this._removeActivelyLoadedPeriod(period, bufferType); } /** * Method to call when the last chronological segment for a given buffer type * is known to have been loaded and is either pushed or in the process of * being pushed to the corresponding MSE `SourceBuffer` or equivalent. * * This method can even be called multiple times in a row as long as the * aforementioned condition is true, if it simplify your code's management. * @param {string} bufferType */ public onLastSegmentFinishedLoading(bufferType: IBufferType): void { const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); if (!streamInfo.hasFinishedLoadingLastPeriod) { streamInfo.hasFinishedLoadingLastPeriod = true; this._checkEndOfStream(); } } /** * Method to call to "cancel" a previous call to * `onLastSegmentFinishedLoading`. * * That is, calling this method indicates that the last chronological segment * of a given buffer type is now either not loaded or it is not known. * * This method can even be called multiple times in a row as long as the * aforementioned condition is true, if it simplify your code's management. * @param {string} bufferType */ public onLastSegmentLoadingResume(bufferType: IBufferType): void { const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); if (streamInfo.hasFinishedLoadingLastPeriod) { streamInfo.hasFinishedLoadingLastPeriod = false; this._checkEndOfStream(); } } /** * Free all resources used by the `ContentTimeBoundariesObserver` and cancels * all recurring processes it performs. */ public dispose() { this.removeEventListener(); this._canceller.cancel(); } private _addActivelyLoadedPeriod(period: IPeriod, bufferType: IBufferType): void { const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); if (!streamInfo.activePeriods.has(period)) { streamInfo.activePeriods.add(period); this._checkCurrentPeriod(); } } private _removeActivelyLoadedPeriod(period: IPeriod, bufferType: IBufferType): void { const streamInfo = this._activeStreams.get(bufferType); if (streamInfo === undefined) { return; } if (streamInfo.activePeriods.has(period)) { streamInfo.activePeriods.removeElement(period); this._checkCurrentPeriod(); } } private _checkCurrentPeriod(): void { if (this._allBufferTypes.length === 0) { return; } const streamInfo = this._activeStreams.get(this._allBufferTypes[0]); if (streamInfo === undefined) { return; } for (const period of streamInfo.activePeriods.toArray()) { let wasFoundInAllTypes = true; for (const bufferType of this._allBufferTypes) { const streamInfo2 = this._activeStreams.get(bufferType); if (streamInfo2 === undefined) { return; } const activePeriods = streamInfo2.activePeriods.toArray(); const hasPeriod = activePeriods.some((p) => p.id === period.id); if (!hasPeriod) { wasFoundInAllTypes = false; break; } } if (wasFoundInAllTypes) { if (this._lastCurrentPeriodId !== period.id) { this._lastCurrentPeriodId = period.id; this.trigger("periodChange", period); } return; } } } private _getManifestEndTime(): IEndingPositionInformation { const endingPosition = this._maximumPositionCalculator.getEndingPosition(); return endingPosition !== undefined ? { isEnd: true, endingPosition } : { isEnd: false, endingPosition: this._maximumPositionCalculator.getMaximumAvailablePosition(), }; } private _lazilyCreateActiveStreamInfo(bufferType: IBufferType): IActiveStreamsInfo { let streamInfo = this._activeStreams.get(bufferType); if (streamInfo === undefined) { streamInfo = { activePeriods: new SortedList((a, b) => a.start - b.start), hasFinishedLoadingLastPeriod: false, }; this._activeStreams.set(bufferType, streamInfo); } return streamInfo; } private _checkEndOfStream(): void { if (!this._manifest.isLastPeriodKnown) { return; } const everyBufferTypeLoaded = this._allBufferTypes.every((bt) => { const streamInfo = this._activeStreams.get(bt); return streamInfo !== undefined && streamInfo.hasFinishedLoadingLastPeriod; }); if (everyBufferTypeLoaded) { this.trigger("endOfStream", null); } else { this.trigger("resumeStream", null); } } } export interface IEndingPositionInformation { /** * The new maximum known position (note that this is the ending position * currently known of the current content, it might be superior to the last * position at which segments are available and it might also evolve over * time), in seconds. */ endingPosition: number; /** * If `true`, the communicated `endingPosition` is the actual end of the content. * It may still be updated due to a track change or to add precision, but it * is still a (rough) estimate of the maximum position that content should * have. * * If `false`, this is the currently known maximum position associated to * the content, but the content is still evolving (typically, new media * segments are still being generated) and as such it can still have a * longer `endingPosition` in the future. */ isEnd: boolean; } /** * Events triggered by a `ContentTimeBoundariesObserver` where the keys are the * event names and the value is the payload of those events. */ export interface IContentTimeBoundariesObserverEvent { /** Triggered when a minor error is encountered. */ warning: IPlayerError; /** Triggered when a new `Period` is currently playing. */ periodChange: IPeriod; /** * Triggered when the ending position of the currently-playing content became * known or changed. */ endingPositionChange: IEndingPositionInformation; /** * Triggered when the last possible chronological segment for all types of * buffers has either been pushed or is being pushed to the corresponding * MSE `SourceBuffer` or equivalent. * As such, the `endOfStream` MSE API might from now be able to be called. * * Note that it is possible to receive this event even if `endOfStream` has * already been called and even if an "endOfStream" event has already been * triggered. */ endOfStream: null; /** * Triggered when the last possible chronological segment for all types of * buffers have NOT been pushed, or if it is not known whether is has been * pushed, and as such any potential pending `endOfStream` MSE API call * need to be cancelled. * * Note that it is possible to receive this event even if `endOfStream` has * not been called and even if an "resumeStream" event has already been * triggered. */ resumeStream: null; } /** * Calculate the last position from the last chosen audio and video Adaptations * for the last Period (or a default one, if no Adaptations has been chosen). * @class MaximumPositionCalculator */ class MaximumPositionCalculator { private _manifest: IManifest; // TODO replicate for the minimum position ? private _lastAudioAdaptation: IAdaptation | undefined | null; private _lastVideoAdaptation: IAdaptation | undefined | null; /** * @param {Object} manifest */ constructor(manifest: IManifest) { this._manifest = manifest; this._lastAudioAdaptation = undefined; this._lastVideoAdaptation = undefined; } /** * Update the last known audio Adaptation for the last Period. * If no Adaptation has been set, it should be set to `null`. * * Allows to calculate the maximum position more precizely in * `getMaximumAvailablePosition` and `getEndingPosition`. * @param {Object|null} adaptation */ public updateLastAudioAdaptation(adaptation: IAdaptation | null): void { this._lastAudioAdaptation = adaptation; } /** * Update the last known video Adaptation for the last Period. * If no Adaptation has been set, it should be set to `null`. * * Allows to calculate the maximum position more precizely in * `getMaximumAvailablePosition` and `getEndingPosition`. * @param {Object|null} adaptation */ public updateLastVideoAdaptation(adaptation: IAdaptation | null): void { this._lastVideoAdaptation = adaptation; } /** * Returns an estimate of the maximum position currently reachable (i.e. * segments are available) under the current circumstances. * @returns {number} */ public getMaximumAvailablePosition(): number { if (this._manifest.isDynamic) { return this._manifest.getMaximumSafePosition(); } if ( this._lastVideoAdaptation === undefined || this._lastAudioAdaptation === undefined ) { return this._manifest.getMaximumSafePosition(); } else if (this._lastAudioAdaptation === null) { if (this._lastVideoAdaptation === null) { return this._manifest.getMaximumSafePosition(); } else { const lastVideoPosition = getLastAvailablePositionFromAdaptation( this._lastVideoAdaptation, ); if (typeof lastVideoPosition !== "number") { return this._manifest.getMaximumSafePosition(); } return lastVideoPosition; } } else if (this._lastVideoAdaptation === null) { const lastAudioPosition = getLastAvailablePositionFromAdaptation( this._lastAudioAdaptation, ); if (typeof lastAudioPosition !== "number") { return this._manifest.getMaximumSafePosition(); } return lastAudioPosition; } else { const lastAudioPosition = getLastAvailablePositionFromAdaptation( this._lastAudioAdaptation, ); const lastVideoPosition = getLastAvailablePositionFromAdaptation( this._lastVideoAdaptation, ); if ( typeof lastAudioPosition !== "number" || typeof lastVideoPosition !== "number" ) { return this._manifest.getMaximumSafePosition(); } else { return Math.min(lastAudioPosition, lastVideoPosition); } } } /** * Returns an estimate of the actual ending position once * the full content is available. * Returns `undefined` if that could not be determined, for various reasons. * @returns {number|undefined} */ public getEndingPosition(): number | undefined { if (!this._manifest.isDynamic) { return this.getMaximumAvailablePosition(); } if ( this._lastVideoAdaptation === undefined || this._lastAudioAdaptation === undefined ) { return undefined; } else if (this._lastAudioAdaptation === null) { if (this._lastVideoAdaptation === null) { return undefined; } else { return getEndingPositionFromAdaptation(this._lastVideoAdaptation) ?? undefined; } } else if (this._lastVideoAdaptation === null) { return getEndingPositionFromAdaptation(this._lastAudioAdaptation) ?? undefined; } else { const lastAudioPosition = getEndingPositionFromAdaptation( this._lastAudioAdaptation, ); const lastVideoPosition = getEndingPositionFromAdaptation( this._lastVideoAdaptation, ); if ( typeof lastAudioPosition !== "number" || typeof lastVideoPosition !== "number" ) { return undefined; } else { return Math.min(lastAudioPosition, lastVideoPosition); } } } } /** * Returns last currently available position from the Adaptation given. * `undefined` if a time could not be found. * `null` if the Adaptation has no segments (it could be that it didn't started or * that it already finished for example). * * We consider the earliest last available position from every Representation * in the given Adaptation. * @param {Object} adaptation * @returns {Number|undefined|null} */ function getLastAvailablePositionFromAdaptation( adaptation: IAdaptation, ): number | undefined | null { const { representations } = adaptation; let min: null | number = null; /** * Some Manifest parsers use the exact same `IRepresentationIndex` reference * for each Representation of a given Adaptation, because in the actual source * Manifest file, indexing data is often defined at Adaptation-level. * This variable allows to optimize the logic here when this is the case. */ let lastIndex: IRepresentationIndex | undefined; for (const representation of representations) { if (representation.index !== lastIndex) { lastIndex = representation.index; const lastPosition = representation.index.getLastAvailablePosition(); if (lastPosition === undefined) { // we cannot tell return undefined; } if (lastPosition !== null) { min = isNullOrUndefined(min) ? lastPosition : Math.min(min, lastPosition); } } } return min; } /** * Returns ending time from the Adaptation given, once all its segments are * available. * `undefined` if a time could not be found. * `null` if the Adaptation has no segments (it could be that it already * finished for example). * * We consider the earliest ending time from every Representation in the given * Adaptation. * @param {Object} adaptation * @returns {Number|undefined|null} */ function getEndingPositionFromAdaptation( adaptation: IAdaptation, ): number | undefined | null { const { representations } = adaptation; let min: null | number = null; /** * Some Manifest parsers use the exact same `IRepresentationIndex` reference * for each Representation of a given Adaptation, because in the actual source * Manifest file, indexing data is often defined at Adaptation-level. * This variable allows to optimize the logic here when this is the case. */ let lastIndex: IRepresentationIndex | undefined; for (const representation of representations) { if (representation.index !== lastIndex) { lastIndex = representation.index; const lastPosition = representation.index.getEnd(); if (lastPosition === undefined) { // we cannot tell return undefined; } if (lastPosition !== null) { min = isNullOrUndefined(min) ? lastPosition : Math.min(min, lastPosition); } } } return min; } interface IActiveStreamsInfo { /** * Active Periods being currently actively loaded by the "Streams". * That is: either this Period's corresponding `Representation` has been * selected or we didn't chose any `Adaptation` for that type), in * chronological order. * * The first chronological Period in that list is the active one for * the current type. */ activePeriods: SortedList<IPeriod>; /** * If `true` the last segment for the last currently known Period has been * pushed for the current Adaptation and Representation choice. */ hasFinishedLoadingLastPeriod: boolean; } export type IContentTimeObserverPlaybackObservation = Pick< IStreamOrchestratorPlaybackObservation, "position" >;