rx-player
Version:
Canal+ HTML5 Video Player
938 lines (878 loc) • 31.1 kB
text/typescript
/**
* 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 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 _mediaElement: IMediaElement;
/** If `true`, a `MediaSource` object is linked to `_mediaElement`. */
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 as the HTMLMediaElement
* had a readyState of `0`.
* This position should be seeked to as soon as the HTMLMediaElement is able
* to handle it.
*/
private _pendingSeek: number | 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;
/**
* Create a new `PlaybackObserver`, which allows to produce new "playback
* observations" on various media events and intervals.
*
* 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 {HTMLMediaElement} mediaElement
* @param {Object} options
*/
constructor(mediaElement: IMediaElement, options: IPlaybackObserverOptions) {
this._internalSeeksIncoming = [];
this._mediaElement = mediaElement;
this._withMediaSource = options.withMediaSource;
this._lowLatencyMode = options.lowLatencyMode;
this._canceller = new TaskCanceller();
this._observationRef = this._createSharedReference();
this._expectedSeekingPosition = null;
this._pendingSeek = null;
const onLoadedMetadata = () => {
if (this._pendingSeek !== null) {
const positionToSeekTo = this._pendingSeek;
this._pendingSeek = null;
this._actuallySetCurrentTime(positionToSeekTo);
}
};
mediaElement.addEventListener("loadedmetadata", onLoadedMetadata);
this._canceller.signal.register(() => {
mediaElement.removeEventListener("loadedmetadata", onLoadedMetadata);
});
}
/**
* 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._mediaElement.currentTime;
}
/**
* Returns the current playback rate advertised by the `HTMLMediaElement`.
* @returns {number}
*/
public getPlaybackRate(): number {
return this._mediaElement.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}
*/
public getIsPaused(): boolean {
return this._mediaElement.paused;
}
/**
* 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
*/
public setCurrentTime(time: number): void {
if (this._mediaElement.readyState >= 1) {
this._actuallySetCurrentTime(time);
} else {
this._internalSeeksIncoming = [];
this._pendingSeek = time;
this._generateObservationForEvent("manual");
}
}
/**
* Update the playback rate of the `HTMLMediaElement`.
* @param {number} playbackRate
*/
public setPlaybackRate(playbackRate: number): void {
this._mediaElement.playbackRate = playbackRate;
}
/**
* Returns the current `readyState` advertised by the `HTMLMediaElement`.
* @returns {number}
*/
public getReadyState(): number {
return this._mediaElement.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(time: number): void {
log.info("API: Seeking internally", time);
this._internalSeeksIncoming.push(time);
this._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 {
SAMPLING_INTERVAL_MEDIASOURCE,
SAMPLING_INTERVAL_LOW_LATENCY,
SAMPLING_INTERVAL_NO_MEDIASOURCE,
} = config.getCurrent();
const returnedSharedReference = new SharedReference(
this._getCurrentObservation("init"),
this._canceller.signal,
);
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;
}
const onInterval = () => {
this._generateObservationForEvent("timeupdate");
};
let intervalId = setInterval(onInterval, interval);
SCANNED_MEDIA_ELEMENTS_EVENTS.map((eventName) => {
const onMediaEvent = () => {
restartInterval();
this._generateObservationForEvent(eventName);
};
this._mediaElement.addEventListener(eventName, onMediaEvent);
this._canceller.signal.register(() => {
this._mediaElement.removeEventListener(eventName, onMediaEvent);
});
});
this._canceller.signal.register(() => {
clearInterval(intervalId);
returnedSharedReference.finish();
});
return returnedSharedReference;
function restartInterval() {
clearInterval(intervalId);
intervalId = setInterval(onInterval, interval);
}
}
private _getCurrentObservation(
event: IPlaybackObserverEventType,
): IPlaybackObservation {
/** Actual event emitted through an observation. */
let tmpEvt: IPlaybackObserverEventType = event;
// 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(this._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;
/** Initially-polled playback observation, before adjustments. */
const mediaTimings = getMediaInfos(this._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(
"API: current media element state tick",
"event",
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",
pendingPosition,
);
}
return timings;
}
private _generateObservationForEvent(event: IPlaybackObserverEventType): void {
const newObservation = this._getCurrentObservation(event);
if (log.hasLevel("DEBUG")) {
log.debug(
"API: current playback timeline:\n" +
prettyPrintBuffered(
newObservation.buffered,
newObservation.position.getPolled(),
),
`\n${event}`,
);
}
this._observationRef.setValue(newObservation);
}
}
/**
* 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 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 =
readyState >= 1 &&
observationEvent !== "loadedmetadata" &&
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): IPlaybackObservation {
const mediaTimings = 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,
});
}