rx-player
Version:
Canal+ HTML5 Video Player
637 lines (592 loc) • 21.4 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 isSeekingApproximate from "../../../compat/is_seeking_approximate";
import config from "../../../config";
import type { IBufferType } from "../../../core/types";
import { MediaError } from "../../../errors";
import log from "../../../log";
import type { IManifestMetadata, IPeriodMetadata } from "../../../manifest";
import { getPeriodAfter } from "../../../manifest";
import { SeekingState } from "../../../playback_observer";
import type {
IMediaElementPlaybackObserver,
IPlaybackObservation,
} from "../../../playback_observer";
import type { IPlayerError } from "../../../public_types";
import EventEmitter from "../../../utils/event_emitter";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import { getNextBufferedTimeRangeGap } from "../../../utils/ranges";
import type { IReadOnlySharedReference } from "../../../utils/reference";
import TaskCanceller from "../../../utils/task_canceller";
import type { IStallingSituation } from "../types";
/**
* Work-around rounding errors with floating points by setting an acceptable,
* very short, deviation when checking equalities.
*/
const EPSILON = 1 / 60;
/**
* Monitor playback, trying to avoid stalling situation.
* If stopping the player to build buffer is needed, temporarily set the
* playback rate (i.e. speed) at `0` until enough buffer is available again.
*
* Emit "stalled" then "unstalled" respectively when an unavoidable stall is
* encountered and exited.
*/
export default class RebufferingController extends EventEmitter<IRebufferingControllerEvent> {
/** Emit the current playback conditions */
private _playbackObserver: IMediaElementPlaybackObserver;
private _manifest: IManifestMetadata | null;
private _speed: IReadOnlySharedReference<number>;
private _isStarted: boolean;
/**
* Every known audio and video buffer discontinuities in chronological
* order (first ordered by Period's start, then by bufferType in any order.
*/
private _discontinuitiesStore: IDiscontinuityStoredInfo[];
private _canceller: TaskCanceller;
/**
* @param {object} playbackObserver - emit the current playback conditions.
* @param {Object} manifest - The Manifest of the currently-played content.
* @param {Object} speed - The last speed set by the user
*/
constructor(
playbackObserver: IMediaElementPlaybackObserver,
manifest: IManifestMetadata | null,
speed: IReadOnlySharedReference<number>,
) {
super();
this._playbackObserver = playbackObserver;
this._manifest = manifest;
this._speed = speed;
this._discontinuitiesStore = [];
this._isStarted = false;
this._canceller = new TaskCanceller();
}
public start(): void {
if (this._isStarted) {
return;
}
this._isStarted = true;
const playbackRateUpdater = new PlaybackRateUpdater(
this._playbackObserver,
this._speed,
);
this._canceller.signal.register(() => {
playbackRateUpdater.dispose();
});
this._playbackObserver.listen(
(observation) => {
const discontinuitiesStore = this._discontinuitiesStore;
const { buffered, position, readyState, rebuffering, freezing } = observation;
const { BUFFER_DISCONTINUITY_THRESHOLD, FREEZING_STALLED_DELAY } =
config.getCurrent();
if (freezing !== null) {
const now = getMonotonicTimeStamp();
if (now - freezing.timestamp > FREEZING_STALLED_DELAY) {
if (rebuffering === null) {
playbackRateUpdater.stopRebuffering();
} else {
playbackRateUpdater.startRebuffering();
}
this.trigger("stalled", "freezing");
return;
}
}
if (rebuffering === null) {
playbackRateUpdater.stopRebuffering();
if (readyState === 1) {
// With a readyState set to 1, we should still not be able to play:
// Return that we're stalled
let reason: IStallingSituation;
if (observation.seeking !== SeekingState.None) {
reason =
observation.seeking === SeekingState.Internal
? "internal-seek"
: "seeking";
} else {
reason = "not-ready";
}
this.trigger("stalled", reason);
return;
}
this.trigger("unstalled", null);
return;
}
// We want to separate a stall situation when a seek is due to a seek done
// internally by the player to when its due to a regular user seek.
const stalledReason =
rebuffering.reason === "seeking" &&
observation.seeking === SeekingState.Internal
? ("internal-seek" as const)
: rebuffering.reason;
if (position.isAwaitingFuturePosition()) {
playbackRateUpdater.stopRebuffering();
log.debug(
"Init",
"let rebuffering happen as we're awaiting a future position",
{
wantedPosition: position.getWanted(),
lastPolledPosition: position.getPolled(),
},
);
} else {
playbackRateUpdater.startRebuffering();
}
if (
this._manifest === null ||
(isSeekingApproximate() &&
// Don't handle discontinuities on devices with broken seeks before
// enough time have passed because seeking brings more risks to
// lead to a lengthy rebuffering-exiting process
getMonotonicTimeStamp() - rebuffering.timestamp <= 1000)
) {
this.trigger("stalled", stalledReason);
return;
}
/** Position at which data is awaited. */
const { position: stalledPosition } = rebuffering;
/**
* We may still be in the process of waiting for a position to be seeked
* to. When calculating a potential position to e.g. skip over
* discontinuities, we should compare it to that "target" position if
* one, not the one we're currently playing.
*/
const targetTime = observation.position.isAwaitingFuturePosition()
? observation.position.getWanted()
: this._playbackObserver.getCurrentTime();
if (
stalledPosition !== null &&
stalledPosition !== undefined &&
this._speed.getValue() > 0
) {
const skippableDiscontinuity = findSeekableDiscontinuity(
discontinuitiesStore,
this._manifest,
stalledPosition,
);
if (skippableDiscontinuity !== null) {
const realSeekTime = skippableDiscontinuity + 0.001;
if (realSeekTime <= targetTime) {
log.info("Init", "position to seek already reached, no seeking", {
targetTime,
realSeekTime,
});
} else {
log.warn("Init", "skippable discontinuity found in the stream", {
lastPolledPosition: position.getPolled(),
realSeekTime,
});
this._playbackObserver.setCurrentTime(realSeekTime);
this.trigger(
"warning",
generateDiscontinuityError(stalledPosition, realSeekTime),
);
return;
}
}
}
const positionBlockedAt = stalledPosition ?? position.getPolled();
// Is it a very short discontinuity in buffer ? -> Seek at the beginning of the
// next range
//
// Discontinuity check in case we are close a buffered range but still
// calculate a stalled state. This is useful for some
// implementation that might drop an injected segment, or in
// case of small discontinuity in the content.
const nextBufferRangeGap = getNextBufferedTimeRangeGap(
buffered,
positionBlockedAt,
);
if (
(!isSeekingApproximate() ||
getMonotonicTimeStamp() - rebuffering.timestamp > 1000) &&
this._speed.getValue() > 0 &&
nextBufferRangeGap < BUFFER_DISCONTINUITY_THRESHOLD
) {
const seekTo = positionBlockedAt + nextBufferRangeGap + EPSILON;
if (targetTime < seekTo) {
log.warn("Init", "discontinuity encountered inferior to the threshold", {
positionBlockedAt,
seekTo,
BUFFER_DISCONTINUITY_THRESHOLD,
});
this._playbackObserver.setCurrentTime(seekTo);
this.trigger(
"warning",
generateDiscontinuityError(positionBlockedAt, seekTo),
);
return;
}
}
// Are we in a discontinuity between periods ? -> Seek at the beginning of the
// next period
for (let i = this._manifest.periods.length - 2; i >= 0; i--) {
const period = this._manifest.periods[i];
if (period.end !== undefined && period.end <= positionBlockedAt) {
if (
this._manifest.periods[i + 1].start > positionBlockedAt &&
this._manifest.periods[i + 1].start > targetTime
) {
const nextPeriod = this._manifest.periods[i + 1];
this._playbackObserver.setCurrentTime(nextPeriod.start);
this.trigger(
"warning",
generateDiscontinuityError(positionBlockedAt, nextPeriod.start),
);
return;
}
break;
}
}
this.trigger("stalled", stalledReason);
},
{ includeLastObservation: true, clearSignal: this._canceller.signal },
);
}
/**
* Update information on an upcoming discontinuity for a given buffer type and
* Period.
* Each new update for the same Period and type overwrites the previous one.
* @param {Object} evt
*/
public updateDiscontinuityInfo(evt: IDiscontinuityEvent): void {
if (!this._isStarted) {
this.start();
}
const lastObservation = this._playbackObserver.getReference().getValue();
updateDiscontinuitiesStore(this._discontinuitiesStore, evt, lastObservation);
}
/**
* Function to call when a Stream is currently locked, i.e. we cannot load
* segments for the corresponding Period and buffer type until it is seeked
* to.
* @param {string} bufferType - Buffer type for which no segment will
* currently load.
* @param {Object} period - Period for which no segment will currently load.
*/
public onLockedStream(bufferType: IBufferType, period: IPeriodMetadata): void {
if (!this._isStarted) {
this.start();
}
const observation = this._playbackObserver.getReference().getValue();
if (
observation.rebuffering === null ||
observation.paused ||
this._speed.getValue() <= 0 ||
(bufferType !== "audio" && bufferType !== "video")
) {
return;
}
const loadedPos = observation.position.getWanted();
const rebufferingPos = observation.rebuffering.position ?? loadedPos;
const lockedPeriodStart = period.start;
if (
loadedPos < lockedPeriodStart &&
Math.abs(rebufferingPos - lockedPeriodStart) < 1
) {
log.warn(
"Init",
"rebuffering because of a future locked stream.\n" +
"Trying to unlock by seeking to the next Period",
);
this._playbackObserver.setCurrentTime(lockedPeriodStart + 0.001);
}
}
/**
* Stops the `RebufferingController` from montoring stalling situations,
* forever.
*/
public destroy(): void {
this._canceller.cancel();
}
}
/**
* @param {Array.<Object>} discontinuitiesStore
* @param {Object} manifest
* @param {number} stalledPosition
* @returns {number|null}
*/
function findSeekableDiscontinuity(
discontinuitiesStore: IDiscontinuityStoredInfo[],
manifest: IManifestMetadata,
stalledPosition: number,
): number | null {
if (discontinuitiesStore.length === 0) {
return null;
}
let maxDiscontinuityEnd: number | null = null;
for (const discontinuityInfo of discontinuitiesStore) {
const { period } = discontinuityInfo;
if (period.start > stalledPosition) {
return maxDiscontinuityEnd;
}
let discontinuityEnd: number | undefined;
if (period.end === undefined || period.end > stalledPosition) {
const { discontinuity, position } = discontinuityInfo;
const { start, end } = discontinuity;
const discontinuityLowerLimit = start ?? position;
if (stalledPosition >= discontinuityLowerLimit - EPSILON) {
if (end === null) {
const nextPeriod = getPeriodAfter(manifest, period);
if (nextPeriod !== null) {
discontinuityEnd = nextPeriod.start + EPSILON;
} else {
log.warn("Init", "discontinuity at Period's end but no next Period", {
periodId: period.id,
});
}
} else if (stalledPosition < end + EPSILON) {
discontinuityEnd = end + EPSILON;
}
}
if (discontinuityEnd !== undefined) {
log.info("Init", "discontinuity found", { stalledPosition, discontinuityEnd });
maxDiscontinuityEnd =
maxDiscontinuityEnd !== null && maxDiscontinuityEnd > discontinuityEnd
? maxDiscontinuityEnd
: discontinuityEnd;
}
}
}
return maxDiscontinuityEnd;
}
/**
* Return `true` if the given event indicates that a discontinuity is present.
* @param {Object} evt
* @returns {Array.<Object>}
*/
function eventContainsDiscontinuity(
evt: IDiscontinuityEvent,
): evt is IDiscontinuityStoredInfo {
return evt.discontinuity !== null;
}
/**
* Update the `discontinuitiesStore` Object with the given event information:
*
* - If that event indicates than no discontinuity is found for a Period
* and buffer type, remove a possible existing discontinuity for that
* combination.
*
* - If that event indicates that a discontinuity can be found for a Period
* and buffer type, replace previous occurences for that combination and
* store it in Period's chronological order in the Array.
* @param {Array.<Object>} discontinuitiesStore
* @param {Object} evt
* @param {Object} observation
* @returns {Array.<Object>}
*/
function updateDiscontinuitiesStore(
discontinuitiesStore: IDiscontinuityStoredInfo[],
evt: IDiscontinuityEvent,
observation: IPlaybackObservation,
): void {
const gcTime = Math.min(
observation.position.getPolled(),
observation.position.getWanted(),
);
// First, perform clean-up of old discontinuities
while (
discontinuitiesStore.length > 0 &&
discontinuitiesStore[0].period.end !== undefined &&
discontinuitiesStore[0].period.end + 10 < gcTime
) {
discontinuitiesStore.shift();
}
const { period, bufferType } = evt;
if (bufferType !== "audio" && bufferType !== "video") {
return;
}
for (let i = 0; i < discontinuitiesStore.length; i++) {
if (discontinuitiesStore[i].period.id === period.id) {
if (discontinuitiesStore[i].bufferType === bufferType) {
if (!eventContainsDiscontinuity(evt)) {
discontinuitiesStore.splice(i, 1);
} else {
discontinuitiesStore[i] = evt;
}
return;
}
} else if (discontinuitiesStore[i].period.start > period.start) {
if (eventContainsDiscontinuity(evt)) {
discontinuitiesStore.splice(i, 0, evt);
}
return;
}
}
if (eventContainsDiscontinuity(evt)) {
discontinuitiesStore.push(evt);
}
return;
}
/**
* Generate error emitted when a discontinuity has been encountered.
* @param {number} stalledPosition
* @param {number} seekTo
* @returns {Error}
*/
function generateDiscontinuityError(stalledPosition: number, seekTo: number): MediaError {
return new MediaError(
"DISCONTINUITY_ENCOUNTERED",
"A discontinuity has been encountered at position " +
String(stalledPosition) +
", seeking at position " +
String(seekTo),
);
}
/**
* Manage playback speed, allowing to force a playback rate of `0` when
* rebuffering is wanted.
*
* Only one `PlaybackRateUpdater` should be created per HTMLMediaElement.
* Note that the `PlaybackRateUpdater` reacts to playback event and wanted
* speed change. You should call its `dispose` method once you don't need it
* anymore.
* @class PlaybackRateUpdater
*/
class PlaybackRateUpdater {
private _playbackObserver: IMediaElementPlaybackObserver;
private _speed: IReadOnlySharedReference<number>;
private _speedUpdateCanceller: TaskCanceller;
private _isRebuffering: boolean;
private _isDisposed: boolean;
/**
* Create a new `PlaybackRateUpdater`.
* @param {Object} playbackObserver
* @param {Object} speed
*/
constructor(
playbackObserver: IMediaElementPlaybackObserver,
speed: IReadOnlySharedReference<number>,
) {
this._speedUpdateCanceller = new TaskCanceller();
this._isRebuffering = false;
this._playbackObserver = playbackObserver;
this._isDisposed = false;
this._speed = speed;
this._updateSpeed();
}
/**
* Force the playback rate to `0`, to start a rebuffering phase.
*
* You can call `stopRebuffering` when you want the rebuffering phase to end.
*/
public startRebuffering(): void {
if (this._isRebuffering || this._isDisposed) {
return;
}
this._isRebuffering = true;
this._speedUpdateCanceller.cancel();
log.info("Init", "Pause playback to build buffer");
this._playbackObserver.setPlaybackRate(0);
}
/**
* If in a rebuffering phase (during which the playback rate is forced to
* `0`), exit that phase to apply the wanted playback rate instead.
*
* Do nothing if not in a rebuffering phase.
*/
public stopRebuffering() {
if (!this._isRebuffering || this._isDisposed) {
return;
}
this._isRebuffering = false;
this._speedUpdateCanceller = new TaskCanceller();
this._updateSpeed();
}
/**
* The `PlaybackRateUpdater` allocate resources to for example listen to
* wanted speed changes and react to it.
*
* Consequently, you should call the `dispose` method, when you don't want the
* `PlaybackRateUpdater` to have an effect anymore.
*/
public dispose() {
this._speedUpdateCanceller.cancel();
this._isDisposed = true;
}
private _updateSpeed() {
this._speed.onUpdate(
(lastSpeed) => {
log.info("Init", "Resume playback speed", { newSpeed: lastSpeed });
this._playbackObserver.setPlaybackRate(lastSpeed);
},
{
clearSignal: this._speedUpdateCanceller.signal,
emitCurrentValue: true,
},
);
}
}
export interface IRebufferingControllerEvent {
stalled: IStallingSituation;
unstalled: null;
needsReload: null;
warning: IPlayerError;
}
/**
* Event indicating that a discontinuity has been found.
* Each event for a `bufferType` and `period` combination replaces the previous
* one.
*/
export interface IDiscontinuityEvent {
/** Buffer type concerned by the discontinuity. */
bufferType: IBufferType;
/** Period concerned by the discontinuity. */
period: IPeriodMetadata;
/**
* Close discontinuity time information.
* `null` if no discontinuity has been detected currently for that buffer
* type and Period.
*/
discontinuity: IDiscontinuityTimeInfo | null;
/**
* Position at which the discontinuity was found.
* Can be important for when a current discontinuity's start is unknown.
*/
position: number;
}
/** Information on a found discontinuity. */
export interface IDiscontinuityTimeInfo {
/**
* Start time of the discontinuity.
* `undefined` for when the start is unknown but the discontinuity was
* currently encountered at the position we were in when this event was
* created.
*/
start: number | undefined;
/**
* End time of the discontinuity, in seconds.
* If `null`, no further segment can be loaded for the corresponding Period.
*/
end: number | null;
}
/**
* Internally stored information about a known discontinuity in the audio or
* video buffer.
*/
interface IDiscontinuityStoredInfo {
/** Buffer type concerned by the discontinuity. */
bufferType: IBufferType;
/** Period concerned by the discontinuity. */
period: IPeriodMetadata;
/** Discontinuity time information. */
discontinuity: IDiscontinuityTimeInfo;
/**
* Position at which the discontinuity was found.
* Can be important for when a current discontinuity's start is unknown.
*/
position: number;
}