rx-player
Version:
Canal+ HTML5 Video Player
430 lines (429 loc) • 18.6 kB
JavaScript
/**
* 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 { MediaError } from "../../../errors";
import log from "../../../log";
import { getPeriodAfter } from "../../../manifest";
import EventEmitter from "../../../utils/event_emitter";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import { getNextBufferedTimeRangeGap } from "../../../utils/ranges";
import TaskCanceller from "../../../utils/task_canceller";
/**
* 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 {
/**
* @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, manifest, speed) {
super();
this._playbackObserver = playbackObserver;
this._manifest = manifest;
this._speed = speed;
this._discontinuitiesStore = [];
this._isStarted = false;
this._canceller = new TaskCanceller();
}
start() {
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;
if (observation.seeking !== 0 /* SeekingState.None */) {
reason =
observation.seeking === 1 /* 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 === 1 /* SeekingState.Internal */
? "internal-seek"
: rebuffering.reason;
if (position.isAwaitingFuturePosition()) {
playbackRateUpdater.stopRebuffering();
log.debug("Init: let rebuffering happen as we're awaiting a future position");
}
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("SA: skippable discontinuity found in the stream", position.getPolled(), realSeekTime);
this._playbackObserver.setCurrentTime(realSeekTime);
this.trigger("warning", generateDiscontinuityError(stalledPosition, realSeekTime));
return;
}
}
}
const positionBlockedAt = stalledPosition !== null && stalledPosition !== void 0 ? 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
*/
updateDiscontinuityInfo(evt) {
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.
*/
onLockedStream(bufferType, period) {
var _a;
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 = (_a = observation.rebuffering.position) !== null && _a !== void 0 ? _a : 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.
*/
destroy() {
this._canceller.cancel();
}
}
/**
* @param {Array.<Object>} discontinuitiesStore
* @param {Object} manifest
* @param {number} stalledPosition
* @returns {number|null}
*/
function findSeekableDiscontinuity(discontinuitiesStore, manifest, stalledPosition) {
if (discontinuitiesStore.length === 0) {
return null;
}
let maxDiscontinuityEnd = null;
for (const discontinuityInfo of discontinuitiesStore) {
const { period } = discontinuityInfo;
if (period.start > stalledPosition) {
return maxDiscontinuityEnd;
}
let discontinuityEnd;
if (period.end === undefined || period.end > stalledPosition) {
const { discontinuity, position } = discontinuityInfo;
const { start, end } = discontinuity;
const discontinuityLowerLimit = start !== null && start !== void 0 ? 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");
}
}
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) {
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, evt, observation) {
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, seekTo) {
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 {
/**
* Create a new `PlaybackRateUpdater`.
* @param {Object} playbackObserver
* @param {Object} speed
*/
constructor(playbackObserver, speed) {
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.
*/
startRebuffering() {
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.
*/
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.
*/
dispose() {
this._speedUpdateCanceller.cancel();
this._isDisposed = true;
}
_updateSpeed() {
this._speed.onUpdate((lastSpeed) => {
log.info("Init: Resume playback speed", lastSpeed);
this._playbackObserver.setPlaybackRate(lastSpeed);
}, {
clearSignal: this._speedUpdateCanceller.signal,
emitCurrentValue: true,
});
}
}