/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.media.GapJumpingController');
goog.provide('shaka.media.StallDetector');
goog.provide('shaka.media.StallDetector.Implementation');
goog.provide('shaka.media.StallDetector.MediaElementImplementation');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.PresentationTimeline');
goog.require('shaka.media.TimeRangesUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.Timer');
/**
* GapJumpingController handles jumping gaps that appear within the content.
* This will only jump gaps between two buffered ranges, so we should not have
* to worry about the availability window.
*
* @implements {shaka.util.IReleasable}
*/
shaka.media.GapJumpingController = class {
/**
* @param {!HTMLMediaElement} video
* @param {!shaka.media.PresentationTimeline} timeline
* @param {shaka.extern.StreamingConfiguration} config
* @param {function(!Event)} onEvent
* Called when an event is raised to be sent to the application.
*/
constructor(video, timeline, config, onEvent) {
/** @private {?function(!Event)} */
this.onEvent_ = onEvent;
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {?shaka.media.PresentationTimeline} */
this.timeline_ = timeline;
/** @private {?shaka.extern.StreamingConfiguration} */
this.config_ = config;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {boolean} */
this.started_ = false;
/** @private {boolean} */
this.seekingEventReceived_ = false;
/** @private {number} */
this.prevReadyState_ = video.readyState;
/** @private {number} */
this.startTime_ = 0;
/** @private {number} */
this.gapsJumped_ = 0;
/** @private {number} */
this.stallsDetected_ = 0;
/**
* The stall detector tries to keep the playhead moving forward. It is
* managed by the gap-jumping controller to avoid conflicts. On some
* platforms, the stall detector is not wanted, so it may be null.
*
* @private {shaka.media.StallDetector}
*/
this.stallDetector_ = this.createStallDetector_();
/** @private {boolean} */
this.hadSegmentAppended_ = false;
this.eventManager_.listen(video, 'waiting', () => this.onPollGapJump_());
/**
* We can't trust |readyState| or 'waiting' events on all platforms. To make
* up for this, we poll the current time. If we think we are in a gap, jump
* out of it.
*
* See: https://bit.ly/2McuXxm and https://bit.ly/2K5xmJO
*
* @private {?shaka.util.Timer}
*/
this.gapJumpTimer_ = new shaka.util.Timer(() => {
this.onPollGapJump_();
}).tickEvery(this.config_.gapJumpTimerTime);
}
/** @override */
release() {
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
if (this.gapJumpTimer_ != null) {
this.gapJumpTimer_.stop();
this.gapJumpTimer_ = null;
}
if (this.stallDetector_) {
this.stallDetector_.release();
this.stallDetector_ = null;
}
this.onEvent_ = null;
this.timeline_ = null;
this.video_ = null;
}
/**
* Called when a segment is appended by StreamingEngine, but not when a clear
* is pending. This means StreamingEngine will continue buffering forward from
* what is buffered. So we know about any gaps before the start.
*/
onSegmentAppended() {
this.hadSegmentAppended_ = true;
if (this.gapJumpTimer_) {
this.gapJumpTimer_.tickEvery(this.config_.gapJumpTimerTime);
}
this.onPollGapJump_();
}
/**
* Called when playback has started and the video element is
* listening for seeks.
*
* @param {number} startTime
*/
onStarted(startTime) {
if (this.video_.seeking && !this.seekingEventReceived_) {
this.seekingEventReceived_ = true;
this.startTime_ = startTime;
}
if (this.gapJumpTimer_) {
this.gapJumpTimer_.tickEvery(this.config_.gapJumpTimerTime);
}
this.onPollGapJump_();
}
/** Called when a seek has started. */
onSeeking() {
this.seekingEventReceived_ = true;
this.hadSegmentAppended_ = false;
if (this.gapJumpTimer_) {
this.gapJumpTimer_.tickEvery(this.config_.gapJumpTimerTime);
}
this.onPollGapJump_();
}
/**
* Returns the total number of playback gaps jumped.
* @return {number}
*/
getGapsJumped() {
return this.gapsJumped_;
}
/**
* Returns the number of playback stalls detected.
* @return {number}
*/
getStallsDetected() {
return this.stallsDetected_;
}
/**
* Called on a recurring timer to check for gaps in the media. This is also
* called in a 'waiting' event.
*
* @private
*/
onPollGapJump_() {
// Don't gap jump before the video is ready to play.
if (this.video_.readyState == 0) {
return;
}
// Do not gap jump if seeking has begun, but the seeking event has not
// yet fired for this particular seek.
if (this.video_.seeking) {
if (!this.seekingEventReceived_) {
return;
}
} else {
this.seekingEventReceived_ = false;
}
// Don't gap jump while paused, so that you don't constantly jump ahead
// while paused on a livestream. We make an exception for time 0, since we
// may be _required_ to seek on startup before play can begin, but only if
// autoplay is enabled.
if (this.video_.paused && (this.video_.currentTime != this.startTime_ ||
(!this.video_.autoplay && this.video_.currentTime == this.startTime_))) {
return;
}
// When the ready state changes, we have moved on, so we should fire the
// large gap event if we see one.
if (this.video_.readyState != this.prevReadyState_) {
this.prevReadyState_ = this.video_.readyState;
}
if (this.stallDetector_ && this.stallDetector_.poll()) {
// Some action was taken by StallDetector, so don't do anything yet.
return;
}
const currentTime = this.video_.currentTime;
const buffered = this.video_.buffered;
const gapDetectionThreshold = this.config_.gapDetectionThreshold;
const gapIndex = shaka.media.TimeRangesUtils.getGapIndex(
buffered, currentTime, gapDetectionThreshold);
// The current time is unbuffered or is too far from a gap.
if (gapIndex == null) {
return;
}
// If we are before the first buffered range, this could be an unbuffered
// seek. So wait until a segment is appended so we are sure it is a gap.
if (gapIndex == 0 && !this.hadSegmentAppended_) {
return;
}
// StreamingEngine can buffer past the seek end, but still don't allow
// seeking past it.
let jumpTo = buffered.start(gapIndex);
const gapPadding = this.config_.gapPadding;
// Workaround for some platforms. On theses platforms video element
// often rounds value we want to set as currentTime and we are not able
// to jump over the gap.
if (gapPadding) {
jumpTo = Math.ceil((jumpTo + gapPadding) * 100) / 100;
}
const seekEnd = this.timeline_.getSeekRangeEnd();
if (jumpTo >= seekEnd) {
return;
}
const jumpSize = jumpTo - currentTime;
// If we jump to exactly the gap start, we may detect a small gap due to
// rounding errors or browser bugs. We can ignore these extremely small
// gaps since the browser should play through them for us.
if (jumpSize < shaka.media.GapJumpingController.BROWSER_GAP_TOLERANCE) {
return;
}
if (gapIndex == 0) {
shaka.log.info(
'Jumping forward', jumpSize,
'seconds because of gap before start time of', jumpTo);
} else {
shaka.log.info(
'Jumping forward', jumpSize, 'seconds because of gap starting at',
buffered.end(gapIndex - 1), 'and ending at', jumpTo);
}
this.video_.currentTime = jumpTo;
// This accounts for the possibility that we jump a gap at the start
// position but we jump _into_ another gap. By setting the start
// position to the new jumpTo we ensure that the check above will
// pass even though the video is still paused.
if (currentTime == this.startTime_) {
this.startTime_ = jumpTo;
}
this.gapsJumped_++;
this.onEvent_(
new shaka.util.FakeEvent(shaka.util.FakeEvent.EventName.GapJumped));
}
/**
* Create and configure a stall detector using the player's streaming
* configuration settings. If the player is configured to have no stall
* detector, this will return |null|.
* @return {shaka.media.StallDetector}
* @private
*/
createStallDetector_() {
if (!this.config_.stallEnabled) {
return null;
}
goog.asserts.assert(this.video_, 'Must have video');
// Cache the values from the config so that changes to the config won't
// change the initialized behaviour.
const threshold = this.config_.stallThreshold;
const skip = this.config_.stallSkip;
const onStall = (at, duration) => {
goog.asserts.assert(this.video_, 'Must have video');
shaka.log.debug(`Stall detected at ${at} for ${duration} seconds.`);
if (skip) {
shaka.log.debug(`Seeking forward ${skip} seconds to break stall.`);
this.video_.currentTime += skip;
} else {
shaka.log.debug('Pausing and unpausing to break stall.');
this.video_.pause();
this.video_.play();
}
this.stallsDetected_++;
this.onEvent_(new shaka.util.FakeEvent(
shaka.util.FakeEvent.EventName.StallDetected));
};
// When we see a stall, we will try to "jump-start" playback by moving the
// playhead forward.
const detector = new shaka.media.StallDetector(
new shaka.media.StallDetector.MediaElementImplementation(this.video_),
threshold, onStall);
return detector;
}
};
/**
* The limit, in seconds, for the gap size that we will assume the browser will
* handle for us.
* @const
*/
shaka.media.GapJumpingController.BROWSER_GAP_TOLERANCE = 0.001;
/**
* Some platforms/browsers can get stuck in the middle of a buffered range (e.g.
* when seeking in a background tab). Detect when we get stuck so that the
* player can respond.
*
* @implements {shaka.util.IReleasable}
* @final
*/
shaka.media.StallDetector = class {
/**
* @param {shaka.media.StallDetector.Implementation} implementation
* @param {number} stallThresholdSeconds
* @param {function(number, number)} onStall
* Callback that should be called when a stall is detected.
*/
constructor(implementation, stallThresholdSeconds, onStall) {
/** @private {shaka.media.StallDetector.Implementation} */
this.implementation_ = implementation;
/** @private {boolean} */
this.wasMakingProgress_ = implementation.shouldBeMakingProgress();
/** @private {number} */
this.value_ = implementation.getPresentationSeconds();
/** @private {number} */
this.lastUpdateSeconds_ = implementation.getWallSeconds();
/** @private {boolean} */
this.didJump_ = false;
/**
* The amount of time in seconds that we must have the same value of
* |value_| before we declare it as a stall.
*
* @private {number}
*/
this.stallThresholdSeconds_ = stallThresholdSeconds;
/** @private {?function(number, number)} */
this.onStall_ = onStall;
}
/** @override */
release() {
// Drop external references to make things easier on the GC.
this.implementation_ = null;
this.onStall_ = null;
}
/**
* Have the detector update itself and fire the "on stall" callback if a stall
* was detected.
*
* @return {boolean} True if action was taken.
*/
poll() {
const impl = this.implementation_;
const shouldBeMakingProgress = impl.shouldBeMakingProgress();
const value = impl.getPresentationSeconds();
const wallTimeSeconds = impl.getWallSeconds();
const acceptUpdate = this.value_ != value ||
this.wasMakingProgress_ != shouldBeMakingProgress;
if (acceptUpdate) {
this.lastUpdateSeconds_ = wallTimeSeconds;
this.value_ = value;
this.wasMakingProgress_ = shouldBeMakingProgress;
this.didJump_ = false;
}
const stallSeconds = wallTimeSeconds - this.lastUpdateSeconds_;
const triggerCallback = stallSeconds >= this.stallThresholdSeconds_ &&
shouldBeMakingProgress && !this.didJump_;
if (triggerCallback) {
if (this.onStall_) {
this.onStall_(this.value_, stallSeconds);
}
this.didJump_ = true;
// If the onStall_ method updated the current time, update our stored
// value so we don't think that was an update.
this.value_ = impl.getPresentationSeconds();
}
return triggerCallback;
}
};
/**
* @interface
*/
shaka.media.StallDetector.Implementation = class {
/**
* Check if the presentation time should be changing. This will return |true|
* when we expect the presentation time to change.
*
* @return {boolean}
*/
shouldBeMakingProgress() {}
/**
* Get the presentation time in seconds.
*
* @return {number}
*/
getPresentationSeconds() {}
/**
* Get the time wall time in seconds.
*
* @return {number}
*/
getWallSeconds() {}
};
/**
* Some platforms/browsers can get stuck in the middle of a buffered range (e.g.
* when seeking in a background tab). Force a seek to help get it going again.
*
* @implements {shaka.media.StallDetector.Implementation}
* @final
*/
shaka.media.StallDetector.MediaElementImplementation = class {
/**
* @param {!HTMLMediaElement} mediaElement
*/
constructor(mediaElement) {
/** @private {!HTMLMediaElement} */
this.mediaElement_ = mediaElement;
}
/** @override */
shouldBeMakingProgress() {
// If we are not trying to play, the lack of change could be misidentified
// as a stall.
if (this.mediaElement_.paused) {
return false;
}
if (this.mediaElement_.playbackRate == 0) {
return false;
}
// If we have don't have enough content, we are not stalled, we are
// buffering.
if (this.mediaElement_.buffered.length == 0) {
return false;
}
return this.hasContentFor_(this.mediaElement_.buffered,
/* timeInSeconds= */ this.mediaElement_.currentTime);
}
/** @override */
getPresentationSeconds() {
return this.mediaElement_.currentTime;
}
/** @override */
getWallSeconds() {
return Date.now() / 1000;
}
/**
* Check if we have buffered enough content to play at |timeInSeconds|. Ignore
* the end of the buffered range since it may not play any more on all
* platforms.
*
* @param {!TimeRanges} buffered
* @param {number} timeInSeconds
* @return {boolean}
* @private
*/
hasContentFor_(buffered, timeInSeconds) {
const TimeRangesUtils = shaka.media.TimeRangesUtils;
for (const {start, end} of TimeRangesUtils.getBufferedInfo(buffered)) {
// Can be as much as 100ms before the range
if (timeInSeconds < start - 0.1) {
continue;
}
// Must be at least 500ms inside the range
if (timeInSeconds > end - 0.5) {
continue;
}
return true;
}
return false;
}
};