UNPKG

rx-player

Version:
589 lines (553 loc) 22.1 kB
import config from "../../../config"; import { formatError } from "../../../errors"; import log from "../../../log"; import type { IRepresentation } from "../../../manifest"; import arrayIncludes from "../../../utils/array_includes"; import { assertUnreachable } from "../../../utils/assert"; import cancellableSleep from "../../../utils/cancellable_sleep"; import noop from "../../../utils/noop"; import objectAssign from "../../../utils/object_assign"; import queueMicrotask from "../../../utils/queue_microtask"; import type { IReadOnlySharedReference } from "../../../utils/reference"; import SharedReference, { createMappedReference } from "../../../utils/reference"; import type { CancellationSignal } from "../../../utils/task_canceller"; import TaskCanceller from "../../../utils/task_canceller"; import type { IRepresentationsChoice, IRepresentationStreamCallbacks, ITerminationOrder, } from "../representation"; import RepresentationStream from "../representation"; import getRepresentationsSwitchingStrategy from "./get_representations_switch_strategy"; import type { IAdaptationStreamArguments, IAdaptationStreamCallbacks } from "./types"; /** * Create new `AdaptationStream` whose task will be to download the media data * for a given Adaptation (i.e. "track"). * * It will rely on the IRepresentationEstimator to choose at any time the * best Representation for this Adaptation and then run the logic to download * and push the corresponding segments in the SegmentSink. * * @param {Object} args - Various arguments allowing the `AdaptationStream` to * determine which Representation to choose and which segments to load from it. * You can check the corresponding type for more information. * @param {Object} callbacks - The `AdaptationStream` relies on a system of * callbacks that it will call on various events. * * Depending on the event, the caller may be supposed to perform actions to * react upon some of them. * * This approach is taken instead of a more classical EventEmitter pattern to: * - Allow callbacks to be called synchronously after the * `AdaptationStream` is called. * - Simplify bubbling events up, by just passing through callbacks * - Force the caller to explicitely handle or not the different events. * * Callbacks may start being called immediately after the `AdaptationStream` * call and may be called until either the `parentCancelSignal` argument is * triggered, or until the `error` callback is called, whichever comes first. * @param {Object} parentCancelSignal - `CancellationSignal` allowing, when * triggered, to immediately stop all operations the `AdaptationStream` is * doing. */ export default function AdaptationStream( { playbackObserver, content, options, representationEstimator, segmentSink, segmentQueueCreator, wantedBufferAhead, maxVideoBufferSize, }: IAdaptationStreamArguments, callbacks: IAdaptationStreamCallbacks, parentCancelSignal: CancellationSignal, ): void { const { manifest, period, adaptation } = content; /** Allows to cancel everything the `AdaptationStream` is doing. */ const adapStreamCanceller = new TaskCanceller(); adapStreamCanceller.linkToSignal(parentCancelSignal); /** * The buffer goal ratio base itself on the value given by `wantedBufferAhead` * to determine a more dynamic buffer goal for a given Representation. * * It can help in cases such as : the current browser has issues with * buffering and tells us that we should try to bufferize less data : * https://developers.google.com/web/updates/2017/10/quotaexceedederror */ const bufferGoalRatioMap: Map<string, number> = new Map(); /** * Emit the currently chosen `Representation`. * `null` if no Representation is chosen for now. */ const currentRepresentation = new SharedReference<IRepresentation | null>( null, adapStreamCanceller.signal, ); /** Stores the last emitted bitrate. */ let previouslyEmittedBitrate: number | undefined; const initialRepIds = content.representations.getValue().representationIds; const initialRepresentations = getRepresentationList( content.adaptation.representations, initialRepIds, ); /** Emit the list of Representation for the adaptive logic. */ const representationsList = new SharedReference( initialRepresentations, adapStreamCanceller.signal, ); // Start-up Adaptive logic const { estimates: estimateRef, callbacks: abrCallbacks } = representationEstimator( { manifest, period, adaptation }, currentRepresentation, representationsList, playbackObserver, adapStreamCanceller.signal, ); const isMediaSegmentQueueInterrupted = new SharedReference<boolean>(false); /** Update the `canLoad` ref on observation update */ playbackObserver.listen( (observation) => { const observationCanStream = observation.canStream ?? true; if (isMediaSegmentQueueInterrupted.getValue() === observationCanStream) { log.debug( "Stream", "isMediaSegmentQueueInterrupted updated to", !observationCanStream, ); isMediaSegmentQueueInterrupted.setValue(!observationCanStream); } }, { clearSignal: adapStreamCanceller.signal }, ); /** Allows a `RepresentationStream` to easily fetch media segments. */ const segmentQueue = segmentQueueCreator.createSegmentQueue( adaptation.type, /* eslint-disable @typescript-eslint/unbound-method */ { onRequestBegin: abrCallbacks.requestBegin, onRequestEnd: abrCallbacks.requestEnd, onProgress: abrCallbacks.requestProgress, onMetrics: abrCallbacks.metrics, }, isMediaSegmentQueueInterrupted, ); /* eslint-enable @typescript-eslint/unbound-method */ /** Used to determine when "fast-switching" is possible. */ const fastSwitchThreshold = new SharedReference<number | undefined>(0); estimateRef.onUpdate( ({ bitrate, knownStableBitrate }) => { if (options.enableFastSwitching) { fastSwitchThreshold.setValueIfChanged(knownStableBitrate); } if (bitrate === undefined || bitrate === previouslyEmittedBitrate) { return; } previouslyEmittedBitrate = bitrate; log.debug("Stream", `new ${adaptation.type} bitrate estimate received from ABR`, { bitrate, }); callbacks.bitrateEstimateChange({ type: adaptation.type, bitrate }); }, { emitCurrentValue: true, clearSignal: adapStreamCanceller.signal }, ); /** * When triggered, cancel all `RepresentationStream`s currently created. * Set to `undefined` initially. */ let cancelCurrentStreams: TaskCanceller | undefined; // Each time the list of wanted Representations changes, we restart the logic content.representations.onUpdate( (val) => { if (cancelCurrentStreams !== undefined) { cancelCurrentStreams.cancel(); } const newRepIds = content.representations.getValue().representationIds; // NOTE: We expect that the rest of the RxPlayer code is already handling // cases where the list of playable `Representation` changes: // decipherability updates, "`Representation` avoidance" etc. const newRepresentations = getRepresentationList( content.adaptation.representations, newRepIds, ); representationsList.setValueIfChanged(newRepresentations); cancelCurrentStreams = new TaskCanceller(); cancelCurrentStreams.linkToSignal(adapStreamCanceller.signal); onRepresentationsChoiceChange(val, cancelCurrentStreams.signal).catch((err) => { if ( cancelCurrentStreams?.isUsed() === true && TaskCanceller.isCancellationError(err) ) { return; } adapStreamCanceller.cancel(); callbacks.error(err); }); }, { clearSignal: adapStreamCanceller.signal, emitCurrentValue: true }, ); return; /** * Function called each time the list of wanted Representations is updated. * * Returns a Promise to profit from async/await syntax. The Promise resolution * does not indicate anything. The Promise may reject however, either on some * error or on some cancellation. * @param {Object} choice - The last Representations choice that has been * made. * @param {Object} fnCancelSignal - `CancellationSignal` allowing to cancel * everything this function is doing and free all related resources. */ async function onRepresentationsChoiceChange( choice: IRepresentationsChoice, fnCancelSignal: CancellationSignal, ): Promise<void> { // First check if we should perform any action regarding what was previously // in the buffer const switchStrat = getRepresentationsSwitchingStrategy( period, adaptation, choice, segmentSink, playbackObserver, ); switch (switchStrat.type) { case "continue": break; // nothing to do case "needs-reload": // Just ask to reload // We begin by scheduling a micro-task to reduce the possibility of race // conditions where the inner logic would be called synchronously before // the next observation (which may reflect very different playback conditions) // is actually received. return queueMicrotask(() => { playbackObserver.listen( () => { if (fnCancelSignal.isCancelled()) { return; } const { DELTA_POSITION_AFTER_RELOAD } = config.getCurrent(); const timeOffset = DELTA_POSITION_AFTER_RELOAD.bitrateSwitch; return callbacks.waitingMediaSourceReload({ bufferType: adaptation.type, period, timeOffset, stayInPeriod: true, }); }, { includeLastObservation: true, clearSignal: fnCancelSignal }, ); }); case "flush-buffer": // Clean + flush case "clean-buffer": // Just clean for (const range of switchStrat.value) { await segmentSink.removeBuffer(range.start, range.end); if (fnCancelSignal.isCancelled()) { return; } } if (switchStrat.type === "flush-buffer") { callbacks.needsBufferFlush(); if (fnCancelSignal.isCancelled()) { return; } } break; default: // Should be impossible assertUnreachable(switchStrat); } recursivelyCreateRepresentationStreams(fnCancelSignal); } /** * Create `RepresentationStream`s starting with the Representation of the last * estimate performed. * Each time a new estimate is made, this function will create a new * `RepresentationStream` corresponding to that new estimate. * @param {Object} fnCancelSignal - `CancellationSignal` which will abort * anything this function is doing and free allocated resources. */ function recursivelyCreateRepresentationStreams( fnCancelSignal: CancellationSignal, ): void { /** * `TaskCanceller` triggered when the current `RepresentationStream` is * terminating and as such the next one might be immediately created * recursively. */ const repStreamTerminatingCanceller = new TaskCanceller(); repStreamTerminatingCanceller.linkToSignal(fnCancelSignal); const { representation } = estimateRef.getValue(); if (representation === null) { return; } /** * Stores the last estimate emitted, starting with `null`. * This allows to easily rely on that value in inner Observables which might also * need the last already-considered value. */ const terminateCurrentStream = new SharedReference<ITerminationOrder | null>( null, repStreamTerminatingCanceller.signal, ); /** Allows to stop listening to estimateRef on the following line. */ estimateRef.onUpdate( (estimate) => { if ( estimate.representation === null || estimate.representation.id === representation.id ) { return; } if (estimate.urgent) { log.info("Stream", "urgent Representation switch", { bufferType: adaptation.type, estimateBitrate: estimate.bitrate, prevRepresentationBitrate: representation.bitrate, newRepresentationBitrate: estimate.representation.bitrate, }); return terminateCurrentStream.setValue({ urgent: true }); } else { log.info("Stream", "slow Representation switch", { bufferType: adaptation.type, estimateBitrate: estimate.bitrate, prevRepresentationBitrate: representation.bitrate, newRepresentationBitrate: estimate.representation.bitrate, }); return terminateCurrentStream.setValue({ urgent: false }); } }, { clearSignal: repStreamTerminatingCanceller.signal, emitCurrentValue: true, }, ); const repInfo = { type: adaptation.type, adaptation, period, representation, }; currentRepresentation.setValue(representation); if (fnCancelSignal.isCancelled()) { return; // previous callback has stopped everything by side-effect } callbacks.representationChange(repInfo); if (fnCancelSignal.isCancelled()) { return; // previous callback has stopped everything by side-effect } const representationStreamCallbacks: IRepresentationStreamCallbacks = { streamStatusUpdate: callbacks.streamStatusUpdate, encryptionDataEncountered: callbacks.encryptionDataEncountered, manifestMightBeOufOfSync: callbacks.manifestMightBeOufOfSync, needsManifestRefresh: callbacks.needsManifestRefresh, inbandEvent: callbacks.inbandEvent, warning: callbacks.warning, error(err: unknown) { adapStreamCanceller.cancel(); callbacks.error(err); }, addedSegment(segmentInfo) { abrCallbacks.addedSegment(segmentInfo); }, terminating() { if (repStreamTerminatingCanceller.isUsed()) { return; // Already handled } repStreamTerminatingCanceller.cancel(); return recursivelyCreateRepresentationStreams(fnCancelSignal); }, }; createRepresentationStream( representation, terminateCurrentStream, representationStreamCallbacks, fnCancelSignal, ); } /** * Create and returns a new `RepresentationStream`, linked to the * given Representation. * @param {Object} representation - The Representation the * `RepresentationStream` has to be created for. * @param {Object} terminateCurrentStream - Gives termination orders, * indicating that the `RepresentationStream` should stop what it's doing. * @param {Object} representationStreamCallbacks - Callbacks to call on * various `RepresentationStream` events. * @param {Object} fnCancelSignal - `CancellationSignal` which will abort * anything this function is doing and free allocated resources. */ function createRepresentationStream( representation: IRepresentation, terminateCurrentStream: IReadOnlySharedReference<ITerminationOrder | null>, representationStreamCallbacks: IRepresentationStreamCallbacks, fnCancelSignal: CancellationSignal, ): void { /** Set to `true` if we've encountered an error with this `RepresentationStream` */ let hasEncounteredError = false; const bufferGoalCanceller = new TaskCanceller(); bufferGoalCanceller.linkToSignal(fnCancelSignal); /** Actually built buffer size, in seconds. */ const bufferGoal = createMappedReference( wantedBufferAhead, (prev) => { return getBufferGoal(representation, prev); }, bufferGoalCanceller.signal, ); const maxBufferSize = adaptation.type === "video" ? maxVideoBufferSize : new SharedReference(Infinity); log.info("Stream", "changing representation", { bufferType: adaptation.type, representationId: representation.id, representationBitrate: representation.bitrate, }); const updatedCallbacks = objectAssign({}, representationStreamCallbacks, { error(err: Error) { if (hasEncounteredError) { // A RepresentationStream might trigger multiple Errors (for example // multiple segments it tried to push at once led to errors). // In that case, we'll only consider the first Error. // // That could mean that we're hiding legitimate issues but handling // multiple of those errors at once is too hard a task for now. log.warn("Stream", "Ignoring RepresentationStream error", err); return; } hasEncounteredError = true; const formattedError = formatError(err, { defaultCode: "NONE", defaultReason: "Unknown `RepresentationStream` error", }); if (formattedError.code !== "BUFFER_FULL_ERROR") { representationStreamCallbacks.error(err); } else { log.warn("Stream", "received BUFFER_FULL_ERROR", { bufferType: adaptation.type, representationBitrate: representation.bitrate, }); const wba = wantedBufferAhead.getValue(); const lastBufferGoalRatio = bufferGoalRatioMap.get(representation.id) ?? 1; // 70%, 49%, 34.3%, 24%, 16.81%, 11.76%, 8.24% and 5.76% const newBufferGoalRatio = lastBufferGoalRatio * 0.7; bufferGoalRatioMap.set(representation.id, newBufferGoalRatio); if (newBufferGoalRatio <= 0.05 || getBufferGoal(representation, wba) <= 2) { representationStreamCallbacks.error(formattedError); return; } // We wait 4 seconds to let the situation evolve by itself before // retrying loading segments with a lower buffer goal cancellableSleep(4000, fnCancelSignal) .then(() => { return createRepresentationStream( representation, terminateCurrentStream, representationStreamCallbacks, fnCancelSignal, ); }) .catch(noop); } }, terminating() { bufferGoalCanceller.cancel(); representationStreamCallbacks.terminating(); }, }); RepresentationStream( { playbackObserver, content: { representation, adaptation, period, manifest }, segmentSink, segmentQueue, terminate: terminateCurrentStream, options: { bufferGoal, maxBufferSize, drmSystemId: options.drmSystemId, fastSwitchThreshold, }, }, updatedCallbacks, fnCancelSignal, ); // reload if the Representation disappears from the Manifest manifest.addEventListener( "manifestUpdate", (updates) => { for (const element of updates.updatedPeriods) { if (element.period.id === period.id) { for (const updated of element.result.updatedAdaptations) { if (updated.adaptation === adaptation.id) { for (const rep of updated.removedRepresentations) { if (rep === representation.id) { if (fnCancelSignal.isCancelled()) { return; } return callbacks.waitingMediaSourceReload({ bufferType: adaptation.type, period, timeOffset: 0, stayInPeriod: true, }); } } } } } else if (element.period.start > period.start) { break; } } }, fnCancelSignal, ); } /** * Returns how much media data should be pre-buffered for this * `Representation`, according to the `wantedBufferAhead` setting and previous * issues encountered with that `Representation`. * @param {Object} representation - The `Representation` you want to buffer. * @param {number} wba - The value of `wantedBufferAhead` set by the user. * @returns {number} */ function getBufferGoal(representation: IRepresentation, wba: number): number { const oldBufferGoalRatio = bufferGoalRatioMap.get(representation.id); const bufferGoalRatio = oldBufferGoalRatio !== undefined ? oldBufferGoalRatio : 1; if (oldBufferGoalRatio === undefined) { bufferGoalRatioMap.set(representation.id, bufferGoalRatio); } if (bufferGoalRatio < 1 && wba === Infinity) { // When `wba` is equal to `Infinity`, dividing it will still make it equal // to `Infinity`. To make the `bufferGoalRatio` still have an effect, we // just starts from a `wba` set to the high value of 5 minutes. return 5 * 60 * 1000 * bufferGoalRatio; } return wba * bufferGoalRatio; } } /** * Construct the list of the `Representation` to play, based on what's supported * and what the API seem to authorize. * @param {Array.<Object>} availableRepresentations - All available * Representation in the current `Adaptation`, including unsupported ones. * @param {Array.<string>} authorizedRepIds - The subset of `Representation` * that the API authorize us to play. * @returns {Array.<Object>} */ function getRepresentationList( availableRepresentations: IRepresentation[], authorizedRepIds: string[], ): IRepresentation[] { const filteredRepresentations = availableRepresentations.filter( (r) => arrayIncludes(authorizedRepIds, r.id) && !r.shouldBeAvoided && r.isPlayable() !== false, ); if (filteredRepresentations.length > 0) { return filteredRepresentations; } // Retry without "`Representation` avoidance" return availableRepresentations.filter( (r) => arrayIncludes(authorizedRepIds, r.id) && r.isPlayable() !== false, ); }