UNPKG

rx-player

Version:
443 lines (442 loc) 22.7 kB
import config from "../../../config"; import { formatError } from "../../../errors"; import log from "../../../log"; 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 SharedReference, { createMappedReference } from "../../../utils/reference"; import TaskCanceller from "../../../utils/task_canceller"; import RepresentationStream from "../representation"; import getRepresentationsSwitchingStrategy from "./get_representations_switch_strategy"; /** * 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, }, callbacks, parentCancelSignal) { 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 = new Map(); /** * Emit the currently chosen `Representation`. * `null` if no Representation is chosen for now. */ const currentRepresentation = new SharedReference(null, adapStreamCanceller.signal); /** Stores the last emitted bitrate. */ let previouslyEmittedBitrate; 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(false); /** Update the `canLoad` ref on observation update */ playbackObserver.listen((observation) => { var _a; const observationCanStream = (_a = observation.canStream) !== null && _a !== void 0 ? _a : 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(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; // 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 === null || cancelCurrentStreams === void 0 ? void 0 : 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, fnCancelSignal) { // 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) { /** * `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(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 = { streamStatusUpdate: callbacks.streamStatusUpdate, encryptionDataEncountered: callbacks.encryptionDataEncountered, manifestMightBeOufOfSync: callbacks.manifestMightBeOufOfSync, needsManifestRefresh: callbacks.needsManifestRefresh, inbandEvent: callbacks.inbandEvent, warning: callbacks.warning, error(err) { 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, terminateCurrentStream, representationStreamCallbacks, fnCancelSignal) { /** 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) { var _a; 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 = (_a = bufferGoalRatioMap.get(representation.id)) !== null && _a !== void 0 ? _a : 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, wba) { 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, authorizedRepIds) { 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); }