UNPKG

rx-player

Version:
1,112 lines (1,041 loc) 37.6 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 { IMediaElement } from "../compat/browser_compatibility_types"; import isSeekingApproximate from "../compat/is_seeking_approximate"; import config from "../config"; import ManualTimeRanges from "../core/segment_sinks/implementations/utils/manual_time_ranges"; import log from "../log"; import getMonotonicTimeStamp from "../utils/monotonic_timestamp"; import noop from "../utils/noop"; import objectAssign from "../utils/object_assign"; import { getBufferedTimeRange } from "../utils/ranges"; import type { IReadOnlySharedReference } from "../utils/reference"; import SharedReference from "../utils/reference"; import type { CancellationSignal } from "../utils/task_canceller"; import TaskCanceller from "../utils/task_canceller"; import type { IMediaInfos, IPlaybackObservation, IPlaybackObserverEventType, IReadOnlyPlaybackObserver, IRebufferingStatus, IFreezingStatus, } from "./types"; import { SeekingState } from "./types"; import generateReadOnlyObserver from "./utils/generate_read_only_observer"; import ObservationPosition from "./utils/observation_position"; /** * HTMLMediaElement Events for which playback observations are calculated and * emitted. */ const SCANNED_MEDIA_ELEMENTS_EVENTS = [ "canplay", "ended", "play", "pause", "seeking", "seeked", "loadedmetadata", "ratechange", ] as const; /** * Class allowing to "observe" current playback conditions so the RxPlayer is * then able to react upon them. * * This is a central class of the RxPlayer as many modules rely on the * `PlaybackObserver` to know the current state of the media being played. * * You can use the PlaybackObserver to either get the last observation * performed, get the current media state or listen to media observation sent * at a regular interval. * * @class {PlaybackObserver} */ export default class PlaybackObserver { /** HTMLMediaElement which we want to observe. */ private _mediaElementRef: SharedReference<IMediaElement | null>; /** If `true`, a `MediaSource` object is linked to the `HTMLMediaElement`. */ private _withMediaSource: boolean; /** * If `true`, we're playing in a low-latency mode, which might have an * influence on some chosen interval values here. */ private _lowLatencyMode: boolean; /** * If set, position which could not yet be seeked to due to either seeking * operations being blocked or due to the HTMLMediaElement having a * `readyState` of `0`. * This position should be seeked to as soon as none of those conditions are * met. */ private _pendingSeek: IPendingSeekInformation | null; /** * The RxPlayer usually wants to differientate when a seek was sourced from * the RxPlayer's internal logic vs when it was sourced from an outside * application code. * * To implement this in the PlaybackObserver, we maintain this counter * allowing to know when a "seeking" event received from a `HTMLMediaElement` * was due to an "internal seek" or an external seek: * - This counter is incremented each time an "internal seek" (seek from the * inside of the RxPlayer has been performed. * - This counter is decremented each time we received a "seeking" event. * * This allows us to correctly characterize seeking events: if the counter is * superior to `0`, it is probably due to an internal "seek". */ private _internalSeeksIncoming: number[]; /** * Stores the last playback observation produced by the `PlaybackObserver`.: */ private _observationRef: SharedReference<IPlaybackObservation>; /** * `TaskCanceller` allowing to free all resources and stop producing playback * observations. */ private _canceller: TaskCanceller; /** * On some devices (right now only seen on Tizen), seeking through the * `currentTime` property can lead to the browser re-seeking once the * segments have been loaded to improve seeking performances (for * example, by seeking right to an intra video frame). * In that case, we risk being in a conflict with that behavior: if for * example we encounter a small discontinuity at the position the browser * seeks to, we will seek over it, the browser would seek back and so on. * * This variable allows to store the maximum known position we were seeking to * so we can detect when the browser seeked back (to avoid performing another * seek after that). When browsers seek back to a position behind a * discontinuity, they are usually able to skip them without our help. */ private _expectedSeekingPosition: number | null; private _observationIntervalId: ReturnType<typeof setInterval> | null; /** * If `true` seek operations asked through the * `MediaElementPlaybackObserver` will not be performed now but after the * `unblockSeeking` method is called. */ private _isSeekBlocked: boolean; /** * Create a new `PlaybackObserver`, which allows to produce new "playback * observations" on various media events and intervals. * * Once a `PlaybackObserver` is created, you will want to "attach" the * media element to it through the `attachMediaElement` method once that * element is ready to play your content. * * Note that creating a `PlaybackObserver` lead to the usage of resources, * such as event listeners which will only be freed once the `stop` method is * called. * @param {Object} options */ constructor(options: IPlaybackObserverOptions) { this._internalSeeksIncoming = []; this._mediaElementRef = new SharedReference<IMediaElement | null>(null); this._withMediaSource = options.withMediaSource; this._lowLatencyMode = options.lowLatencyMode; this._canceller = new TaskCanceller(); this._expectedSeekingPosition = null; this._pendingSeek = null; this._isSeekBlocked = false; this._observationIntervalId = null; this._observationRef = this._createSharedReference(); this._canceller.signal.register(() => { this._mediaElementRef.finish(); }); } /** * "Link" the actual `HTMLMediaElement` to this `PlaybackObserver`. * * This is done in a step separate from the constructor to allow complex * situations where you want to inialize the polling logic before the media * element is ready to play your content (e.g. when pre-loading the next * content). * * @param {HTMLMediaElement} mediaElement - The `HTMLMediaElement` on which * the content plays. */ public attachMediaElement(mediaElement: IMediaElement): void { const prevMediaElement = this._mediaElementRef.getValue(); if (prevMediaElement !== null) { throw new Error("A media element was already attached to this PlaybackObserver"); } this._mediaElementRef.setValue(mediaElement); if (this._canceller.isUsed()) { return; } this._registerLoadedMetadataEvent(mediaElement); this._restartInterval(); this._registerMediaElementEvents(mediaElement); this._generateObservationForEvent("init"); if (this._canceller.isUsed()) { return; } if (mediaElement.readyState >= 1) { this._onLoadedMetadataEvent(); } } /** * Stop the `PlaybackObserver` from emitting playback observations and free all * resources reserved to emitting them such as event listeners and intervals. * * Once `stop` is called, no new playback observation will ever be emitted. * * Note that it is important to call stop once the `PlaybackObserver` is no * more needed to avoid unnecessarily leaking resources. */ public stop() { this._canceller.cancel(); } /** * Returns the current position advertised by the `HTMLMediaElement`, in * seconds. * @returns {number} */ public getCurrentTime(): number { return ( this._mediaElementRef.getValue()?.currentTime ?? this._observationRef.getValue().position.getPolled() ); } /** * Returns the current playback rate advertised by the `HTMLMediaElement`. * @returns {number|undefined} */ public getPlaybackRate(): number { return ( this._mediaElementRef.getValue()?.playbackRate ?? this._observationRef.getValue().playbackRate ); } /** * Returns the current `paused` status advertised by the `HTMLMediaElement`. * * Use this instead of the same status emitted on an observation when you want * to be sure you're using the current value. * @returns {boolean|undefined} */ public getIsPaused(): boolean { return ( this._mediaElementRef.getValue()?.paused ?? this._observationRef.getValue().paused ); } /** * Prevent seeking operations from being performed from inside the * `MediaElementPlaybackObserver` until the `unblockSeeking` method is called. * * You might want to call this method when you want to ensure that the next * seek operation on the media element happens at a specific, controlled, * point in time. */ public blockSeeking(): void { this._isSeekBlocked = true; } /** * Remove seeking block created by the `blockSeeking` method if it was called. * * If a seek operation was requested while the block was active, the * `MediaElementPlaybackObserver` will seek at the last seeked position as * soon as possible (either right now, or when the `readyState` of the * `HTMLMediaElement` will have at least reached the `"HAVE_METADATA"` state). */ public unblockSeeking(): void { if (this._isSeekBlocked) { this._isSeekBlocked = false; const mediaElement = this._mediaElementRef.getValue(); if ( mediaElement !== null && mediaElement.readyState >= 1 && this._pendingSeek !== null ) { const { position: positionToSeekTo, isInternal } = this._pendingSeek; this._pendingSeek = null; this._actuallySetCurrentTime(mediaElement, positionToSeekTo, isInternal); } } } /** * Returns `true` if seeking operations are currently blocked due to a call to * `blockSeeking` that was not yet undone by a call to `unblockSeeking`. * @returns {boolean} - `true` if seeking operations are blocked currently. */ public isSeekingBlocked(): boolean { return this._isSeekBlocked; } /** * Seek operations, as performed by the `setCurrentTime` method, might be not * yet performed due to either of those reasons: * * - Seek operations are blocked due to a call to the `blockSeeking` method. * * - The `HTMLMediaElement`'s `readyState` property has not yet reached the * `"HAVE_METADATA"` state. * * Under any of those two conditions, this method will return the position * that is planned to be seeked to as soon as both conditions are not met * anymore. * * If seeks are possible right now, no seek should be "pending" and as such * this method will return `null`. * * @returns {Object|null} - If a seek is planned, the position to seek to. * `null` otherwise. */ public getPendingSeekInformation(): IPendingSeekInformation | null { return this._pendingSeek; } /** * Update the current position (seek) on the `HTMLMediaElement`, by giving a * new position in seconds. * * Note that seeks performed through this method are caracherized as * "internal" seeks. They don't result into the exact same playback * observation than regular seeks (which most likely comes from the outside, * e.g. the user). * @param {number} time * @param {boolean} [isInternal=true] - If `false`, the seek was performed by * the user. */ public setCurrentTime(time: number, isInternal: boolean = true): void { const mediaElement = this._mediaElementRef.getValue(); if (mediaElement !== null && !this._isSeekBlocked && mediaElement.readyState >= 1) { this._actuallySetCurrentTime(mediaElement, time, isInternal); } else { this._pendingSeek = { position: time, isInternal }; this._internalSeeksIncoming = []; this._generateObservationForEvent("manual"); } } /** * Update the playback rate of the `HTMLmediaElement`. * @param {number} playbackRate */ public setPlaybackRate(playbackRate: number): void { const mediaElement = this._mediaElementRef.getValue(); if (mediaElement === null) { return; } mediaElement.playbackRate = playbackRate; } /** * Returns the current `readyState` advertised by the `HTMLmediaElement`. * @returns {number} */ public getReadyState(): number { return ( this._mediaElementRef.getValue()?.readyState ?? this._observationRef.getValue().readyState ); } /** * Returns an `IReadOnlySharedReference` storing the last playback observation * produced by the `PlaybackObserver` and updated each time a new one is * produced. * * This value can then be for example listened to to be notified of future * playback observations. * * @returns {Object} */ public getReference(): IReadOnlySharedReference<IPlaybackObservation> { return this._observationRef; } /** * Register a callback so it regularly receives playback observations. * @param {Function} cb * @param {Object} params - Configuration parameters: * - `includeLastObservation`: If set to `true` the last observation will * be first emitted synchronously. * - `clearSignal`: If set, the callback will be unregistered when this * CancellationSignal emits. */ public listen( cb: (observation: IPlaybackObservation, stopListening: () => void) => void, params: { includeLastObservation?: boolean | undefined; clearSignal: CancellationSignal; }, ) { if (this._canceller.isUsed() || params.clearSignal.isCancelled()) { return noop; } this._observationRef.onUpdate(cb, { clearSignal: params.clearSignal, emitCurrentValue: params.includeLastObservation, }); } /** * Generate a new playback observer which can listen to other * properties and which can only be accessed to read observations (e.g. * it cannot ask to perform a seek). * * The object returned will respect the `IReadOnlyPlaybackObserver` interface * and will inherit this `PlaybackObserver`'s lifecycle: it will emit when * the latter emits. * * As argument, this method takes a function which will allow to produce * the new set of properties to be present on each observation. * @param {Function} transform * @returns {Object} */ public deriveReadOnlyObserver<TDest>( transform: ( observationRef: IReadOnlySharedReference<IPlaybackObservation>, cancellationSignal: CancellationSignal, ) => IReadOnlySharedReference<TDest>, ): IReadOnlyPlaybackObserver<TDest> { return generateReadOnlyObserver(this, transform, this._canceller.signal); } private _actuallySetCurrentTime( mediaElement: IMediaElement, time: number, isInternal: boolean, ): void { log.info("media", "Actually seeking.", { time, isInternal }); if (isInternal) { this._internalSeeksIncoming.push(time); } mediaElement.currentTime = time; } /** * Creates the `IReadOnlySharedReference` that will generate playback * observations. * @returns {Object} */ private _createSharedReference(): SharedReference<IPlaybackObservation> { if (this._observationRef !== undefined) { return this._observationRef; } const returnedSharedReference = new SharedReference( this._getCurrentObservation("init"), this._canceller.signal, ); this._restartInterval(); const mediaElement = this._mediaElementRef.getValue(); if (mediaElement !== null) { this._registerMediaElementEvents(mediaElement); } this._canceller.signal.register(() => { if (this._observationIntervalId !== null) { clearInterval(this._observationIntervalId); } returnedSharedReference.finish(); }); return returnedSharedReference; } private _getCurrentObservation( event: IPlaybackObserverEventType, ): IPlaybackObservation { /** Actual event emitted through an observation. */ let tmpEvt: IPlaybackObserverEventType = event; const mediaElement = this._mediaElementRef.getValue(); // NOTE: `this._observationRef` may be `undefined` because we might here be // called in the constructor when that property is not yet set. const previousObservation = this._observationRef === undefined ? getInitialObservation(mediaElement) : this._observationRef.getValue(); /** * If `true`, there is a seek operation ongoing but it was done from the * `PlaybackObserver`'s `setCurrentTime` method, not from external code. */ let isInternalSeeking = false; /** If set, the position for which we plan to seek to as soon as possible. */ let pendingPosition: number | null = this._pendingSeek?.position ?? null; /** Initially-polled playback observation, before adjustments. */ const mediaTimings = mediaElement === null ? getEmptyMediaInfo() : getMediaInfos(mediaElement); const { buffered, readyState, position, seeking } = mediaTimings; if (tmpEvt === "seeking") { // We just began seeking. // Let's find out if the seek is internal or external and handle approximate // seeking if (this._internalSeeksIncoming.length > 0) { isInternalSeeking = true; tmpEvt = "internal-seeking"; const startedInternalSeekTime = this._internalSeeksIncoming.shift(); this._expectedSeekingPosition = isSeekingApproximate() ? Math.max(position, startedInternalSeekTime ?? 0) : position; } else { this._expectedSeekingPosition = position; } } else if (seeking) { // we're still seeking, this time without a "seeking" event so it's an // already handled one, keep track of the last wanted position we wanted // to seek to, to work-around devices re-seeking silently. this._expectedSeekingPosition = Math.max( position, this._expectedSeekingPosition ?? 0, ); } else if ( isSeekingApproximate() && this._expectedSeekingPosition !== null && position < this._expectedSeekingPosition ) { // We're on a target with aproximate seeking, we're not seeking anymore, but // we're not yet at the expected seeking position. // Signal to the rest of the application that the intented position is not // the current position but the one contained in `this._expectedSeekingPosition` pendingPosition = this._expectedSeekingPosition; } else { this._expectedSeekingPosition = null; } if ( seeking && previousObservation.seeking === SeekingState.Internal && event !== "seeking" ) { isInternalSeeking = true; } // NOTE: Devices which decide to not exactly seek where we want to seek // (e.g. to start on an intra video frame instead) bother us when it // comes to defining rebuffering and freezing statuses, because we might // for example believe that we're rebuffering whereas it's just that the // device decided to bring us just before the buffered data. // // After many major issues on those devices (namely Tizen), we decided to // just consider the position WE wanted to seek to as the real current // position for buffer-starvation related metrics like the current range, // the bufferGap, the rebuffering status, the freezing status... // // This specificity should only apply to those devices, other devices rely // on the actual current position. const basePosition = this._expectedSeekingPosition ?? position; let currentRange; let bufferGap; if (!this._withMediaSource && buffered.length === 0 && readyState >= 3) { // Sometimes `buffered` stay empty for directfile contents yet we are able // to play. This seems to be linked to browser-side issues but has been // encountered on enough platforms (Chrome desktop and PlayStation 4's // WebKit for us to do something about it in the player. currentRange = undefined; bufferGap = undefined; } else { currentRange = getBufferedTimeRange(buffered, basePosition); bufferGap = currentRange !== null ? currentRange.end - basePosition : // TODO null/0 would probably be // more appropriate Infinity; } const fullyLoaded = hasLoadedUntilTheEnd( basePosition, currentRange, mediaTimings.ended, mediaTimings.duration, this._lowLatencyMode, ); const rebufferingStatus = getRebufferingStatus({ previousObservation, currentObservation: mediaTimings, basePosition, observationEvent: tmpEvt, lowLatencyMode: this._lowLatencyMode, withMediaSource: this._withMediaSource, bufferGap, fullyLoaded, }); const freezingStatus = getFreezingStatus( previousObservation, mediaTimings, tmpEvt, bufferGap, ); let seekingState: SeekingState; if (isInternalSeeking) { seekingState = SeekingState.Internal; } else if (seeking) { seekingState = SeekingState.External; } else { seekingState = SeekingState.None; } const timings: IPlaybackObservation = objectAssign({}, mediaTimings, { position: new ObservationPosition(mediaTimings.position, pendingPosition), event: tmpEvt, seeking: seekingState, rebuffering: rebufferingStatus, freezing: freezingStatus, bufferGap, currentRange, fullyLoaded, }); if (log.hasLevel("DEBUG")) { log.debug("media", "Current media element state tick.", { evt: timings.event, position: timings.position.getPolled(), seeking: timings.seeking, internalSeek: isInternalSeeking, rebuffering: timings.rebuffering !== null, freezing: timings.freezing !== null, ended: timings.ended, paused: timings.paused, playbackRate: timings.playbackRate, readyState: timings.readyState, pendingPosition, }); } return timings; } private _generateObservationForEvent(event: IPlaybackObserverEventType): void { const newObservation = this._getCurrentObservation(event); if (log.hasLevel("DEBUG")) { log.debug( "media", "current playback timeline:\n" + prettyPrintBuffered( newObservation.buffered, newObservation.position.getPolled(), ), `\n${event}`, ); } this._observationRef.setValue(newObservation); } private _restartInterval() { const { SAMPLING_INTERVAL_MEDIASOURCE, SAMPLING_INTERVAL_LOW_LATENCY, SAMPLING_INTERVAL_NO_MEDIASOURCE, } = config.getCurrent(); let interval: number; if (this._lowLatencyMode) { interval = SAMPLING_INTERVAL_LOW_LATENCY; } else if (this._withMediaSource) { interval = SAMPLING_INTERVAL_MEDIASOURCE; } else { interval = SAMPLING_INTERVAL_NO_MEDIASOURCE; } if (this._observationIntervalId !== null) { clearInterval(this._observationIntervalId); } this._observationIntervalId = setInterval( () => this._generateObservationForEvent("timeupdate"), interval, ); } private _registerMediaElementEvents(mediaElement: IMediaElement): void { SCANNED_MEDIA_ELEMENTS_EVENTS.map((eventName) => { const onMediaEvent = () => { this._restartInterval(); this._generateObservationForEvent(eventName); }; mediaElement.addEventListener(eventName, onMediaEvent); this._canceller.signal.register(() => { mediaElement.removeEventListener(eventName, onMediaEvent); }); }); } private _registerLoadedMetadataEvent(mediaElement: IMediaElement): void { const loadedmetadataCb = this._onLoadedMetadataEvent.bind(this); mediaElement.addEventListener("loadedmetadata", loadedmetadataCb); this._canceller.signal.register(() => { mediaElement.removeEventListener("loadedmetadata", loadedmetadataCb); }); } private _onLoadedMetadataEvent(): void { const mediaElement = this._mediaElementRef.getValue(); if (mediaElement !== null && this._pendingSeek !== null && !this._isSeekBlocked) { const { position: positionToSeekTo, isInternal } = this._pendingSeek; this._pendingSeek = null; this._actuallySetCurrentTime(mediaElement, positionToSeekTo, isInternal); } } } /** * Returns the amount of time in seconds the buffer should have ahead of the * current position before resuming playback. Based on the infos of the * rebuffering status. * * Waiting time differs between a rebuffering happening after a "seek" or one * happening after a buffer starvation occured. * @param {Object|null} rebufferingStatus * @param {Boolean} lowLatencyMode * @returns {Number} */ function getRebufferingEndGap( rebufferingStatus: IRebufferingStatus, lowLatencyMode: boolean, ): number { if (rebufferingStatus === null) { return 0; } const suffix: "LOW_LATENCY" | "DEFAULT" = lowLatencyMode ? "LOW_LATENCY" : "DEFAULT"; const { RESUME_GAP_AFTER_SEEKING, RESUME_GAP_AFTER_NOT_ENOUGH_DATA, RESUME_GAP_AFTER_BUFFERING, } = config.getCurrent(); switch (rebufferingStatus.reason) { case "seeking": return RESUME_GAP_AFTER_SEEKING[suffix]; case "not-ready": return RESUME_GAP_AFTER_NOT_ENOUGH_DATA[suffix]; case "buffering": return RESUME_GAP_AFTER_BUFFERING[suffix]; } } /** * @param {Object} currentRange * @param {Number} duration * @param {Boolean} lowLatencyMode * @returns {Boolean} */ function hasLoadedUntilTheEnd( currentTime: number, currentRange: { start: number; end: number } | null | undefined, ended: boolean, duration: number, lowLatencyMode: boolean, ): boolean { const { REBUFFERING_GAP } = config.getCurrent(); const suffix: "LOW_LATENCY" | "DEFAULT" = lowLatencyMode ? "LOW_LATENCY" : "DEFAULT"; if (currentRange === undefined) { return ended && Math.abs(duration - currentTime) <= REBUFFERING_GAP[suffix]; } return currentRange !== null && duration - currentRange.end <= REBUFFERING_GAP[suffix]; } /** * Get polled media metrics for when the `HTMLMediaElement` is not yet "attached" * to the `PlaybackObserver`. * * Those metrics actually corresponds to an idle media element. * @returns {Object} */ function getEmptyMediaInfo(): IMediaInfos { return { buffered: new ManualTimeRanges(), position: 0, duration: NaN, ended: false, paused: true, playbackRate: 1, readyState: 0, seeking: false, }; } /** * Get basic playback information. * @param {HTMLmediaElement} mediaElement * @returns {Object} */ function getMediaInfos(mediaElement: IMediaElement): IMediaInfos { const { buffered, currentTime, duration, ended, paused, playbackRate, readyState, seeking, } = mediaElement; return { buffered, position: currentTime, duration, ended, paused, playbackRate, readyState, seeking, }; } /** * Infer the rebuffering status. * @param {Object} options * @returns {Object|null} */ function getRebufferingStatus({ previousObservation, currentObservation, basePosition, observationEvent, withMediaSource, lowLatencyMode, bufferGap, fullyLoaded, }: { /** Previous Playback Observation produced. */ previousObservation: IPlaybackObservation; /** New media information collected. */ currentObservation: IMediaInfos; /** * Position we should consider as the position we're currently playing. * Might be different than the `position` advertised by `currentObservation` * in cases where the device just decides to seek back a little without * authorization. */ basePosition: number; /** Name of the event that triggers this new observation. */ observationEvent: IPlaybackObserverEventType; /** * If `true`, we're relying on MSE API for the current content, if `false`, * we're relying on regular HTML5 video playback handled by the browser. */ withMediaSource: boolean; /** If `true`, we're playing the current content in low-latency mode. */ lowLatencyMode: boolean; /** * Amount of media data we've ahead in the current buffered range of media * buffer. * * `Infinity` if we've no data. * `undefined` if we cannot determine this due to a browser issue. */ bufferGap: number | undefined; /** If `true` the content is loaded until its maximum position. */ fullyLoaded: boolean; }): IRebufferingStatus | null { const { REBUFFERING_GAP } = config.getCurrent(); const { position: currentTime, paused, readyState, ended } = currentObservation; const { rebuffering: prevRebuffering, event: prevEvt, position: prevTime, } = previousObservation; const canSwitchToRebuffering = prevRebuffering === null && !(fullyLoaded || ended); let rebufferEndPosition: number | null | undefined = null; let shouldRebuffer: boolean | undefined; let shouldStopRebuffer: boolean | undefined; const rebufferGap = lowLatencyMode ? REBUFFERING_GAP.LOW_LATENCY : REBUFFERING_GAP.DEFAULT; if (withMediaSource) { if (canSwitchToRebuffering) { if (bufferGap === Infinity) { shouldRebuffer = true; rebufferEndPosition = basePosition; } else if (bufferGap === undefined) { if (readyState < 3) { shouldRebuffer = true; rebufferEndPosition = undefined; } } else if (bufferGap <= rebufferGap) { shouldRebuffer = true; rebufferEndPosition = basePosition + bufferGap; } } else if (prevRebuffering !== null) { const resumeGap = getRebufferingEndGap(prevRebuffering, lowLatencyMode); if ( (shouldRebuffer !== true && prevRebuffering !== null && readyState > 1 && (fullyLoaded || ended || (bufferGap !== undefined && isFinite(bufferGap) && bufferGap > resumeGap))) || (bufferGap === undefined && readyState >= 3) ) { shouldStopRebuffer = true; } else if (bufferGap === undefined) { rebufferEndPosition = undefined; } else if (bufferGap === Infinity) { rebufferEndPosition = basePosition; } else if (bufferGap <= resumeGap) { rebufferEndPosition = basePosition + bufferGap; } } } // when using a direct file, the media will stall and unstall on its // own, so we only try to detect when the media timestamp has not changed // between two consecutive timeupdates else { if ( canSwitchToRebuffering && // TODO what about when paused: e.g. when loading initially the content ((!paused && observationEvent === "timeupdate" && prevEvt === "timeupdate" && currentTime === prevTime.getPolled()) || (observationEvent === "seeking" && (bufferGap === Infinity || (bufferGap === undefined && readyState < 3)))) ) { shouldRebuffer = true; } else if ( prevRebuffering !== null && ((observationEvent !== "seeking" && currentTime !== prevTime.getPolled()) || observationEvent === "canplay" || (bufferGap === undefined && readyState >= 3) || (bufferGap !== undefined && bufferGap < Infinity && (bufferGap > getRebufferingEndGap(prevRebuffering, lowLatencyMode) || fullyLoaded || ended))) ) { shouldStopRebuffer = true; } } if (shouldStopRebuffer === true) { return null; } else if (shouldRebuffer === true || prevRebuffering !== null) { let reason: "seeking" | "not-ready" | "buffering" | "internal-seek"; if ( observationEvent === "seeking" || (prevRebuffering !== null && prevRebuffering.reason === "seeking") ) { reason = "seeking"; } else if (currentObservation.seeking) { reason = "seeking"; } else if (readyState === 1) { reason = "not-ready"; } else { reason = "buffering"; } if (prevRebuffering !== null && prevRebuffering.reason === reason) { return { reason: prevRebuffering.reason, timestamp: prevRebuffering.timestamp, position: rebufferEndPosition, }; } return { reason, timestamp: getMonotonicTimeStamp(), position: rebufferEndPosition, }; } return null; } /** * Detect if the current media can be considered as "freezing" (i.e. not * advancing for unknown reasons). * * Returns a corresponding `IFreezingStatus` object if that's the case and * `null` if not. * @param {Object} prevObservation * @param {Object} currentInfo * @param {string} currentEvt * @param {number|undefined} bufferGap * @returns {Object|null} */ function getFreezingStatus( prevObservation: IPlaybackObservation, currentInfo: IMediaInfos, currentEvt: IPlaybackObserverEventType, bufferGap: number | undefined, ): IFreezingStatus | null { const { MINIMUM_BUFFER_AMOUNT_BEFORE_FREEZING } = config.getCurrent(); if (prevObservation.freezing !== null) { if ( currentInfo.ended || currentInfo.paused || currentInfo.readyState === 0 || currentInfo.playbackRate === 0 || prevObservation.position.getPolled() !== currentInfo.position ) { return null; // Quit freezing status } return prevObservation.freezing; // Stay in it } return currentEvt === "timeupdate" && bufferGap !== undefined && bufferGap > MINIMUM_BUFFER_AMOUNT_BEFORE_FREEZING && !currentInfo.ended && !currentInfo.paused && currentInfo.readyState >= 1 && currentInfo.playbackRate !== 0 && currentInfo.position === prevObservation.position.getPolled() ? { timestamp: getMonotonicTimeStamp() } : null; } export interface IPlaybackObserverOptions { withMediaSource: boolean; lowLatencyMode: boolean; } /** * Pretty print a TimeRanges Object, to see the current content of it in a * one-liner string. * * @example * This function is called by giving it directly the TimeRanges, such as: * ```js * prettyPrintBuffered(document.getElementsByTagName("video")[0].buffered); * ``` * * Let's consider this possible return: * * ``` * 0.00|==29.95==|29.95 ~30.05~ 60.00|==29.86==|89.86 * ^14 * ``` * This means that our video element has 29.95 seconds of buffer between 0 and * 29.95 seconds. * Then 30.05 seconds where no buffer is found. * Then 29.86 seconds of buffer between 60.00 and 89.86 seconds. * * A caret on the second line indicates the current time we're at. * The number coming after it is the current time. * @param {TimeRanges} buffered * @param {number} currentTime * @returns {string} */ function prettyPrintBuffered(buffered: TimeRanges, currentTime: number): string { let str = ""; let currentTimeStr = ""; for (let i = 0; i < buffered.length; i++) { const start = buffered.start(i); const end = buffered.end(i); const fixedStart = start.toFixed(2); const fixedEnd = end.toFixed(2); const fixedDuration = (end - start).toFixed(2); const newIntervalStr = `${fixedStart}|==${fixedDuration}==|${fixedEnd}`; str += newIntervalStr; if (currentTimeStr.length === 0 && end > currentTime) { const padBefore = str.length - Math.floor(newIntervalStr.length / 2); currentTimeStr = " ".repeat(padBefore) + `^${currentTime}`; } if (i < buffered.length - 1) { const nextStart = buffered.start(i + 1); const fixedDiff = (nextStart - end).toFixed(2); const holeStr = ` ~${fixedDiff}~ `; str += holeStr; if (currentTimeStr.length === 0 && currentTime < nextStart) { const padBefore = str.length - Math.floor(holeStr.length / 2); currentTimeStr = " ".repeat(padBefore) + `^${currentTime}`; } } } if (currentTimeStr.length === 0) { currentTimeStr = " ".repeat(str.length) + `^${currentTime}`; } return str + "\n" + currentTimeStr; } /** * Generate the initial playback observation for when no event has yet been * emitted to lead to one. * @param {HTMLMediaElement} mediaElement * @returns {Object} */ function getInitialObservation(mediaElement: IMediaElement | null): IPlaybackObservation { const mediaTimings = mediaElement === null ? getEmptyMediaInfo() : getMediaInfos(mediaElement); return objectAssign(mediaTimings, { rebuffering: null, event: "init" as const, seeking: SeekingState.None, position: new ObservationPosition(mediaTimings.position, null), freezing: null, bufferGap: 0, currentRange: null, fullyLoaded: false, }); } interface IPendingSeekInformation { /** Position to seek to. */ position: number; /** If `true`, the seek was performed by the RxPlayer's internal logic. */ isInternal: boolean; }