UNPKG

rx-player

Version:
364 lines (363 loc) 17.6 kB
import { config } from "../../../experimental"; import log from "../../../log"; import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp"; /** * "Freezing" is a complex situation indicating that playback is not advancing * despite no valid reason for it not to. * * Technically, there's multiple scenarios in which the RxPlayer will consider * the stream as "freezing" and try to fix it. * One of those scenarios is when there's a * `HTMLMediaElement.prototype.readyState` set to `1` (which is how the browser * tells us that it doesn't have any data to play), despite the fact that * there's actually data in the buffer. * * The `MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING` is the minimum * buffer size in seconds after which we will suspect a "freezing" scenario if * the `readyState` is still at `1`. */ const MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING = 6; /** * The amount of milliseconds since a freeze was detected from which we consider * that the freeze should be worked around: either by flushing buffers, * reloading, or any other kind of strategies. * * Before that delay, will continue to wait to see if the browser succeeds to * un-freeze by itself. */ const FREEZING_FOR_TOO_LONG_DELAY = 4000; /** * To avoid handling freezes (e.g. "reloading" or "seeking") in a loop when * things go wrong, we have a security delay in milliseconds, this * `MINIMUM_TIME_BETWEEN_FREEZE_HANDLING` constant, which we'll await between * un-freezing attempts. */ const MINIMUM_TIME_BETWEEN_FREEZE_HANDLING = 6000; /** * We maintain here a short-term history of what segments have been played * recently, to then implement heuristics detecting if a freeze was due to a * particular quality or track. * * To avoid growing that history indefinitely in size, we only save data * corresponding to the last `MAXIMUM_SEGMENT_HISTORY_RETENTION_TIME` * milliseconds from now. */ const MAXIMUM_SEGMENT_HISTORY_RETENTION_TIME = 60000; /** * Sometimes playback is stuck for no known reason, despite having data in * buffers. * * This can be due to relatively valid cause: performance being slow on the * device making the content slow to start up, decryption keys not being * obtained / usable yet etc. * * Yet in many cases, this is abnormal and may lead to being stuck at the same * position and video frame indefinitely. * * For those situations, we have a series of tricks and heuristic, which are * implemented by the `FreezeResolver`. * * @class FreezeResolver */ export default class FreezeResolver { constructor(segmentSinksStore) { this._segmentSinksStore = segmentSinksStore; this._decipherabilityFreezeStartingTimestamp = null; this._ignoreFreezeUntil = null; this._lastFlushAttempt = null; this._lastSegmentInfo = { audio: [], video: [], }; } /** * Check that playback is not freezing, and if it is, return a solution that * should be attempted to unfreeze it. * * Returns `null` either when there's no freeze happening or if there's one * but there's nothing we should do about it yet. * * Refer to the returned type's definition for more information. * * @param {Object} observation - The last playback observation produced, it * has to be recent (just triggered for example). * @returns {Object|null} */ onNewObservation(observation) { var _a, _b; const now = getMonotonicTimeStamp(); this._addPositionToHistory(observation, now); if (this._ignoreFreezeUntil !== null && now < this._ignoreFreezeUntil) { return null; } this._ignoreFreezeUntil = null; const { UNFREEZING_SEEK_DELAY, UNFREEZING_DELTA_POSITION, FREEZING_FLUSH_FAILURE_DELAY, } = config.getCurrent(); const { readyState, rebuffering, freezing, fullyLoaded } = observation; const freezingPosition = observation.position.getPolled(); const bufferGap = normalizeBufferGap(observation.bufferGap); /** If set to `true`, we consider playback "frozen" */ const isFrozen = freezing !== null || // When rebuffering, `freezing` might be not set as we're actively pausing // playback. Yet, rebuffering occurences can also be abnormal, such as // when enough buffer is constructed but with a low readyState (those are // generally decryption issues). (rebuffering !== null && readyState === 1 && (bufferGap >= MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING || fullyLoaded)); if (!isFrozen) { this._decipherabilityFreezeStartingTimestamp = null; return null; } const freezingTs = (_b = (_a = freezing === null || freezing === void 0 ? void 0 : freezing.timestamp) !== null && _a !== void 0 ? _a : rebuffering === null || rebuffering === void 0 ? void 0 : rebuffering.timestamp) !== null && _b !== void 0 ? _b : null; log.info("FR: Freeze detected", freezingTs, now - (freezingTs !== null && freezingTs !== void 0 ? freezingTs : now)); /** * If `true`, we recently tried to "flush" to unstuck playback but playback * is still stuck */ const recentFlushAttemptFailed = this._lastFlushAttempt !== null && now - this._lastFlushAttempt.timestamp < FREEZING_FLUSH_FAILURE_DELAY.MAXIMUM && now - this._lastFlushAttempt.timestamp >= FREEZING_FLUSH_FAILURE_DELAY.MINIMUM && Math.abs(freezingPosition - this._lastFlushAttempt.position) < FREEZING_FLUSH_FAILURE_DELAY.POSITION_DELTA; if (recentFlushAttemptFailed) { const secondUnfreezeStrat = this._getStrategyIfFlushingFails(freezingPosition); this._decipherabilityFreezeStartingTimestamp = null; this._ignoreFreezeUntil = now + MINIMUM_TIME_BETWEEN_FREEZE_HANDLING; return secondUnfreezeStrat; } const decipherabilityFreezeStrat = this._checkForDecipherabilityRelatedFreeze(observation, now); if (decipherabilityFreezeStrat !== null) { return decipherabilityFreezeStrat; } if (freezingTs !== null && now - freezingTs > UNFREEZING_SEEK_DELAY) { this._lastFlushAttempt = { timestamp: now, position: freezingPosition + UNFREEZING_DELTA_POSITION, }; log.debug("FR: trying to flush to un-freeze"); this._decipherabilityFreezeStartingTimestamp = null; this._ignoreFreezeUntil = now + MINIMUM_TIME_BETWEEN_FREEZE_HANDLING; return { type: "flush", value: { relativeSeek: UNFREEZING_DELTA_POSITION }, }; } return null; } /** * Performs decipherability-related checks if it makes sense. * * If decipherability-related checks have been performed **AND** an * un-freezing strategy has been selected by this method, then return * an object describing this wanted unfreezing strategy. * * If this method decides to take no action for now, it returns `null`. * @param {Object} observation - playback observation that has just been * performed. * @param {number} now - Monotonically-raising timestamp for the current * time. * @returns {Object|null} */ _checkForDecipherabilityRelatedFreeze(observation, now) { const { readyState, rebuffering, freezing, fullyLoaded } = observation; const bufferGap = normalizeBufferGap(observation.bufferGap); const rebufferingForTooLong = rebuffering !== null && now - rebuffering.timestamp > FREEZING_FOR_TOO_LONG_DELAY; const frozenForTooLong = freezing !== null && now - freezing.timestamp > FREEZING_FOR_TOO_LONG_DELAY; const hasDecipherabilityFreezePotential = (rebufferingForTooLong || frozenForTooLong) && (bufferGap >= MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING || fullyLoaded) && readyState <= 1; if (!hasDecipherabilityFreezePotential) { this._decipherabilityFreezeStartingTimestamp = null; } else if (this._decipherabilityFreezeStartingTimestamp === null) { log.debug("FR: Start of a potential decipherability freeze detected"); this._decipherabilityFreezeStartingTimestamp = now; } const shouldHandleDecipherabilityFreeze = this._decipherabilityFreezeStartingTimestamp !== null && getMonotonicTimeStamp() - this._decipherabilityFreezeStartingTimestamp > FREEZING_FOR_TOO_LONG_DELAY; let hasOnlyDecipherableSegments = true; let isClear = true; for (const ttype of ["audio", "video"]) { const status = this._segmentSinksStore.getStatus(ttype); if (status.type === "initialized") { for (const segment of status.value.getLastKnownInventory()) { const { representation } = segment.infos; if (representation.decipherable === false) { log.warn("FR: we have undecipherable segments left in the buffer, reloading"); this._decipherabilityFreezeStartingTimestamp = null; this._ignoreFreezeUntil = now + MINIMUM_TIME_BETWEEN_FREEZE_HANDLING; return { type: "reload", value: null }; } else if (representation.contentProtections !== undefined) { isClear = false; if (representation.decipherable !== true) { hasOnlyDecipherableSegments = false; } } } } } if (shouldHandleDecipherabilityFreeze && !isClear && hasOnlyDecipherableSegments) { log.warn("FR: we are frozen despite only having decipherable " + "segments left in the buffer, reloading"); this._decipherabilityFreezeStartingTimestamp = null; this._ignoreFreezeUntil = now + MINIMUM_TIME_BETWEEN_FREEZE_HANDLING; return { type: "reload", value: null }; } return null; } /** * This method should only be called if a "flush" strategy has recently be * taken to try to unfreeze playback yet playback is still frozen. * * It considers the current played content and returns a more-involved * unfreezing strategy (most often reload-related) to try to unfree playback. * @param {number} freezingPosition - The playback position at which we're * currently frozen. * @returns {Object} */ _getStrategyIfFlushingFails(freezingPosition) { log.warn("FR: A recent flush seemed to have no effect on freeze, checking for transitions"); /** Contains Representation we might want to avoid after the following algorithm */ const toAvoid = []; for (const ttype of ["audio", "video"]) { const segmentList = this._lastSegmentInfo[ttype]; if (segmentList.length === 0) { // There's no buffered segment for that type, go to next type continue; } /** Played history information on the current segment we're stuck on. */ let currentSegmentEntry = segmentList[segmentList.length - 1]; if (currentSegmentEntry.segment === null) { // No segment currently played for that given type, go to next type continue; } /** Metadata on the segment currently being played. */ const currentSegment = currentSegmentEntry.segment; /** * Set to the first previous segment which is linked to a different * Representation. */ let previousRepresentationEntry; // Now find `previousRepresentationEntry` and `currentSegmentEntry`. for (let i = segmentList.length - 2; i >= 0; i--) { const segment = segmentList[i]; if (segment.segment === null) { // Before the current segment, there was no segment being played previousRepresentationEntry = segment; break; } else if (segment.segment.infos.representation.uniqueId !== currentSegment.infos.representation.uniqueId && currentSegmentEntry.timestamp - segment.timestamp < 5000) { // Before the current segment, there was a segment of a different // Representation being played previousRepresentationEntry = segment; break; } else if (segment.segment.start === currentSegment.start && // Ignore history entry concerning the same segment more than 3 // seconds of playback behind - we don't want to compare things // that happended too long ago. freezingPosition - segment.position < 3000) { // We're still playing the last segment at that point, update it. // // (We may be playing, or be freezing, on the current segment for some // time, this allows to consider a more precize timestamp at which we // switched segments). currentSegmentEntry = segment; } } if (previousRepresentationEntry === undefined || previousRepresentationEntry.segment === null) { log.debug("FR: Freeze when beginning to play a content, try avoiding this quality"); toAvoid.push({ adaptation: currentSegment.infos.adaptation, period: currentSegment.infos.period, representation: currentSegment.infos.representation, }); } else if (currentSegment.infos.period.id !== previousRepresentationEntry.segment.infos.period.id) { log.debug("FR: Freeze when switching Period, reloading"); return { type: "reload", value: null }; } else if (currentSegment.infos.representation.uniqueId !== previousRepresentationEntry.segment.infos.representation.uniqueId) { log.warn("FR: Freeze when switching Representation, avoiding", currentSegment.infos.representation.bitrate); toAvoid.push({ adaptation: currentSegment.infos.adaptation, period: currentSegment.infos.period, representation: currentSegment.infos.representation, }); } } if (toAvoid.length > 0) { return { type: "avoid-representations", value: toAvoid }; } else { log.debug("FR: Reloading because flush doesn't work"); return { type: "reload", value: null }; } } /** * Add entry to `this._lastSegmentInfo` for the position that is currently * played according to the given `observation`. * * @param {Object} observation * @param {number} currentTimestamp */ _addPositionToHistory(observation, currentTimestamp) { var _a, _b; const position = observation.position.getPolled(); for (const ttype of ["audio", "video"]) { const status = this._segmentSinksStore.getStatus(ttype); if (status.type === "initialized") { for (const segment of status.value.getLastKnownInventory()) { if (((_a = segment.bufferedStart) !== null && _a !== void 0 ? _a : segment.start) <= position && ((_b = segment.bufferedEnd) !== null && _b !== void 0 ? _b : segment.end) > position) { this._lastSegmentInfo[ttype].push({ segment, position, timestamp: currentTimestamp, }); } } } else { this._lastSegmentInfo[ttype].push({ segment: null, position, timestamp: currentTimestamp, }); } if (this._lastSegmentInfo[ttype].length > 100) { const toRemove = this._lastSegmentInfo[ttype].length - 100; this._lastSegmentInfo[ttype].splice(0, toRemove); } const removalTs = currentTimestamp - MAXIMUM_SEGMENT_HISTORY_RETENTION_TIME; let i; for (i = 0; i < this._lastSegmentInfo[ttype].length; i++) { if (this._lastSegmentInfo[ttype][i].timestamp > removalTs) { break; } } if (i > 0) { this._lastSegmentInfo[ttype].splice(0, i); } } } } /** * Constructs a `bufferGap` value that is more usable than what the * `PlaybackObserver` returns: * - it cannot be `undefined` * - its weird `Infinity` value is translated to the more explicit `0`. * @param {number|undefined} bufferGap * @returns {number} */ function normalizeBufferGap(bufferGap) { return bufferGap !== undefined && isFinite(bufferGap) ? bufferGap : 0; }