/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.media.MediaSourcePlayhead');
goog.provide('shaka.media.Playhead');
goog.provide('shaka.media.SrcEqualsPlayhead');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.GapJumpingController');
goog.require('shaka.media.StallDetector');
goog.require('shaka.media.StallDetector.MediaElementImplementation');
goog.require('shaka.media.TimeRangesUtils');
goog.require('shaka.media.VideoWrapper');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.MediaReadyState');
goog.require('shaka.util.Platform');
goog.require('shaka.util.Timer');
goog.requireType('shaka.media.PresentationTimeline');
/**
* Creates a Playhead, which manages the video's current time.
*
* The Playhead provides mechanisms for setting the presentation's start time,
* restricting seeking to valid time ranges, and stopping playback for startup
* and re-buffering.
*
* @extends {shaka.util.IReleasable}
* @interface
*/
shaka.media.Playhead = class {
/**
* Called when the Player is ready to begin playback. Anything that depends
* on setStartTime() should be done here, not in the constructor.
*
* @see https://github.com/shaka-project/shaka-player/issues/4244
*/
ready() {}
/**
* Set the start time. If the content has already started playback, this will
* be ignored.
*
* @param {number} startTime
*/
setStartTime(startTime) {}
/**
* Get the number of playback stalls detected by the StallDetector.
*
* @return {number}
*/
getStallsDetected() {}
/**
* Get the number of playback gaps jumped by the GapJumpingController.
*
* @return {number}
*/
getGapsJumped() {}
/**
* Get the current playhead position. The position will be restricted to valid
* time ranges.
*
* @return {number}
*/
getTime() {}
/**
* Notify the playhead that the buffered ranges have changed.
*/
notifyOfBufferingChange() {}
};
/**
* A playhead implementation that only relies on the media element.
*
* @implements {shaka.media.Playhead}
* @final
*/
shaka.media.SrcEqualsPlayhead = class {
/**
* @param {!HTMLMediaElement} mediaElement
*/
constructor(mediaElement) {
/** @private {HTMLMediaElement} */
this.mediaElement_ = mediaElement;
/** @private {boolean} */
this.started_ = false;
/** @private {?number} */
this.startTime_ = null;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
}
/** @override */
ready() {
goog.asserts.assert(
this.mediaElement_ != null,
'Playhead should not be released before calling ready()',
);
// We listen for the loaded-data-event so that we know when we can
// interact with |currentTime|.
const onLoaded = () => {
if (this.startTime_ == null ||
(this.startTime_ == 0 && this.mediaElement_.duration != Infinity)) {
this.started_ = true;
} else {
const currentTime = this.mediaElement_.currentTime;
let newTime = this.startTime_;
// Using the currentTime allows using a negative number in Live HLS
if (this.startTime_ < 0) {
newTime = Math.max(0, currentTime + this.startTime_);
}
if (currentTime != newTime) {
// Startup is complete only when the video element acknowledges the
// seek.
this.eventManager_.listenOnce(this.mediaElement_, 'seeking', () => {
this.started_ = true;
});
this.mediaElement_.currentTime = newTime;
} else {
this.started_ = true;
}
}
};
shaka.util.MediaReadyState.waitForReadyState(this.mediaElement_,
HTMLMediaElement.HAVE_CURRENT_DATA,
this.eventManager_, () => {
onLoaded();
});
}
/** @override */
release() {
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
this.mediaElement_ = null;
}
/** @override */
setStartTime(startTime) {
// If we have already started playback, ignore updates to the start time.
// This is just to make things consistent.
this.startTime_ = this.started_ ? this.startTime_ : startTime;
}
/** @override */
getTime() {
// If we have not started playback yet, return the start time. However once
// we start playback we assume that we can always return the current time.
const time = this.started_ ?
this.mediaElement_.currentTime :
this.startTime_;
// In the case that we have not started playback, but the start time was
// never set, we don't know what the start time should be. To ensure we
// always return a number, we will default back to 0.
return time || 0;
}
/** @override */
getStallsDetected() {
return 0;
}
/** @override */
getGapsJumped() {
return 0;
}
/** @override */
notifyOfBufferingChange() {}
};
/**
* A playhead implementation that relies on the media element and a manifest.
* When provided with a manifest, we can provide more accurate control than
* the SrcEqualsPlayhead.
*
* TODO: Clean up and simplify Playhead. There are too many layers of, methods
* for, and conditions on timestamp adjustment.
*
* @implements {shaka.media.Playhead}
* @final
*/
shaka.media.MediaSourcePlayhead = class {
/**
* @param {!HTMLMediaElement} mediaElement
* @param {shaka.extern.Manifest} manifest
* @param {shaka.extern.StreamingConfiguration} config
* @param {?number} startTime
* The playhead's initial position in seconds. If null, defaults to the
* start of the presentation for VOD and the live-edge for live.
* @param {function()} onSeek
* Called when the user agent seeks to a time within the presentation
* timeline.
* @param {function(!Event)} onEvent
* Called when an event is raised to be sent to the application.
*/
constructor(mediaElement, manifest, config, startTime, onSeek, onEvent) {
/**
* The seek range must be at least this number of seconds long. If it is
* smaller than this, change it to be this big so we don't repeatedly seek
* to keep within a zero-width window.
*
* This is 3s long, to account for the weaker hardware on platforms like
* Chromecast.
*
* @private {number}
*/
this.minSeekRange_ = 3.0;
/** @private {HTMLMediaElement} */
this.mediaElement_ = mediaElement;
/** @private {shaka.media.PresentationTimeline} */
this.timeline_ = manifest.presentationTimeline;
/** @private {?shaka.extern.StreamingConfiguration} */
this.config_ = config;
/** @private {function()} */
this.onSeek_ = onSeek;
/** @private {?number} */
this.lastCorrectiveSeek_ = null;
/** @private {shaka.media.StallDetector} */
this.stallDetector_ =
this.createStallDetector_(mediaElement, config, onEvent);
/** @private {shaka.media.GapJumpingController} */
this.gapController_ = new shaka.media.GapJumpingController(
mediaElement,
manifest.presentationTimeline,
config,
this.stallDetector_,
onEvent);
/** @private {shaka.media.VideoWrapper} */
this.videoWrapper_ = new shaka.media.VideoWrapper(
mediaElement,
() => this.onSeeking_(),
(realStartTime) => this.onStarted_(realStartTime),
() => this.getStartTime_(startTime));
/** @type {shaka.util.Timer} */
this.checkWindowTimer_ = new shaka.util.Timer(() => {
this.onPollWindow_();
});
}
/** @override */
ready() {
this.checkWindowTimer_.tickEvery(/* seconds= */ 0.25);
}
/** @override */
release() {
if (this.videoWrapper_) {
this.videoWrapper_.release();
this.videoWrapper_ = null;
}
if (this.gapController_) {
this.gapController_.release();
this.gapController_= null;
}
if (this.checkWindowTimer_) {
this.checkWindowTimer_.stop();
this.checkWindowTimer_ = null;
}
this.config_ = null;
this.timeline_ = null;
this.videoWrapper_ = null;
this.mediaElement_ = null;
this.onSeek_ = () => {};
}
/** @override */
setStartTime(startTime) {
this.videoWrapper_.setTime(startTime);
}
/** @override */
getTime() {
const time = this.videoWrapper_.getTime();
// Although we restrict the video's currentTime elsewhere, clamp it here to
// ensure timing issues don't cause us to return a time outside the segment
// availability window. E.g., the user agent seeks and calls this function
// before we receive the 'seeking' event.
//
// We don't buffer when the livestream video is paused and the playhead time
// is out of the seek range; thus, we do not clamp the current time when the
// video is paused.
// https://github.com/shaka-project/shaka-player/issues/1121
if (this.mediaElement_.readyState > 0 && !this.mediaElement_.paused) {
return this.clampTime_(time);
}
return time;
}
/** @override */
getStallsDetected() {
return this.stallDetector_ ? this.stallDetector_.getStallsDetected() : 0;
}
/** @override */
getGapsJumped() {
return this.gapController_.getGapsJumped();
}
/**
* Gets the playhead's initial position in seconds.
*
* @param {?number} startTime
* @return {number}
* @private
*/
getStartTime_(startTime) {
if (startTime == null) {
if (this.timeline_.getDuration() < Infinity) {
// If the presentation is VOD, or if the presentation is live but has
// finished broadcasting, then start from the beginning.
startTime = this.timeline_.getSeekRangeStart();
} else {
// Otherwise, start near the live-edge.
startTime = this.timeline_.getSeekRangeEnd();
}
} else if (startTime < 0) {
// For live streams, if the startTime is negative, start from a certain
// offset time from the live edge. If the offset from the live edge is
// not available, start from the current available segment start point
// instead, handled by clampTime_().
startTime = this.timeline_.getSeekRangeEnd() + startTime;
}
return this.clampSeekToDuration_(this.clampTime_(startTime));
}
/** @override */
notifyOfBufferingChange() {
this.gapController_.onSegmentAppended();
}
/**
* Called on a recurring timer to keep the playhead from falling outside the
* availability window.
*
* @private
*/
onPollWindow_() {
// Don't catch up to the seek range when we are paused or empty.
// The definition of "seeking" says that we are seeking until the buffered
// data intersects with the playhead. If we fall outside of the seek range,
// it doesn't matter if we are in a "seeking" state. We can and should go
// ahead and catch up while seeking.
if (this.mediaElement_.readyState == 0 || this.mediaElement_.paused) {
return;
}
const currentTime = this.videoWrapper_.getTime();
let seekStart = this.timeline_.getSeekRangeStart();
const seekEnd = this.timeline_.getSeekRangeEnd();
if (seekEnd - seekStart < this.minSeekRange_) {
seekStart = seekEnd - this.minSeekRange_;
}
if (currentTime < seekStart) {
// The seek range has moved past the playhead. Move ahead to catch up.
const targetTime = this.reposition_(currentTime);
shaka.log.info('Jumping forward ' + (targetTime - currentTime) +
' seconds to catch up with the seek range.');
this.mediaElement_.currentTime = targetTime;
}
}
/**
* Called when the video element has started up and is listening for new seeks
*
* @param {number} startTime
* @private
*/
onStarted_(startTime) {
this.gapController_.onStarted(startTime);
}
/**
* Handles when a seek happens on the video.
*
* @private
*/
onSeeking_() {
this.gapController_.onSeeking();
const currentTime = this.videoWrapper_.getTime();
const targetTime = this.reposition_(currentTime);
const gapLimit = shaka.media.GapJumpingController.BROWSER_GAP_TOLERANCE;
if (Math.abs(targetTime - currentTime) > gapLimit) {
let canCorrectiveSeek = false;
if (shaka.util.Platform.isSeekingSlow()) {
// You can only seek like this every so often. This is to prevent an
// infinite loop on systems where changing currentTime takes a
// significant amount of time (e.g. Chromecast).
const time = Date.now() / 1000;
const seekDelay = shaka.util.Platform.isFuchsiaCastDevice() ? 3 : 1;
if (!this.lastCorrectiveSeek_ ||
this.lastCorrectiveSeek_ < time - seekDelay) {
this.lastCorrectiveSeek_ = time;
canCorrectiveSeek = true;
}
} else {
canCorrectiveSeek = true;
}
if (canCorrectiveSeek) {
this.videoWrapper_.setTime(targetTime);
return;
}
}
shaka.log.v1('Seek to ' + currentTime);
this.onSeek_();
}
/**
* Clamp seek times and playback start times so that we never seek to the
* presentation duration. Seeking to or starting at duration does not work
* consistently across browsers.
*
* @see https://github.com/shaka-project/shaka-player/issues/979
* @param {number} time
* @return {number} The adjusted seek time.
* @private
*/
clampSeekToDuration_(time) {
const duration = this.timeline_.getDuration();
if (time >= duration) {
goog.asserts.assert(this.config_.durationBackoff >= 0,
'Duration backoff must be non-negative!');
return duration - this.config_.durationBackoff;
}
return time;
}
/**
* Computes a new playhead position that's within the presentation timeline.
*
* @param {number} currentTime
* @return {number} The time to reposition the playhead to.
* @private
*/
reposition_(currentTime) {
goog.asserts.assert(
this.config_,
'Cannot reposition playhead when it has beeen destroyed');
/** @type {function(number)} */
const isBuffered = (playheadTime) => shaka.media.TimeRangesUtils.isBuffered(
this.mediaElement_.buffered, playheadTime);
const rebufferingGoal = this.config_.rebufferingGoal;
const safeSeekOffset = this.config_.safeSeekOffset;
let start = this.timeline_.getSeekRangeStart();
const end = this.timeline_.getSeekRangeEnd();
const duration = this.timeline_.getDuration();
if (end - start < this.minSeekRange_) {
start = end - this.minSeekRange_;
}
// With live content, the beginning of the availability window is moving
// forward. This means we cannot seek to it since we will "fall" outside
// the window while we buffer. So we define a "safe" region that is far
// enough away. For VOD, |safe == start|.
const safe = this.timeline_.getSafeSeekRangeStart(rebufferingGoal);
// These are the times to seek to rather than the exact destinations. When
// we seek, we will get another event (after a slight delay) and these steps
// will run again. So if we seeked directly to |start|, |start| would move
// on the next call and we would loop forever.
const seekStart = this.timeline_.getSafeSeekRangeStart(safeSeekOffset);
const seekSafe = this.timeline_.getSafeSeekRangeStart(
rebufferingGoal + safeSeekOffset);
if (currentTime >= duration) {
shaka.log.v1('Playhead past duration.');
return this.clampSeekToDuration_(currentTime);
}
if (currentTime > end) {
shaka.log.v1('Playhead past end.');
// We remove the safeSeekEndOffset of the seek end to avoid the player
// to be block at the edge in a live stream
return end - this.config_.safeSeekEndOffset;
}
if (currentTime < start) {
if (isBuffered(seekStart)) {
shaka.log.v1('Playhead before start & start is buffered');
return seekStart;
} else {
shaka.log.v1('Playhead before start & start is unbuffered');
return seekSafe;
}
}
if (currentTime >= safe || isBuffered(currentTime)) {
shaka.log.v1('Playhead in safe region or in buffered region.');
return currentTime;
} else {
shaka.log.v1('Playhead outside safe region & in unbuffered region.');
return seekSafe;
}
}
/**
* Clamps the given time to the seek range.
*
* @param {number} time The time in seconds.
* @return {number} The clamped time in seconds.
* @private
*/
clampTime_(time) {
const start = this.timeline_.getSeekRangeStart();
if (time < start) {
return start;
}
const end = this.timeline_.getSeekRangeEnd();
if (time > end) {
return end;
}
return time;
}
/**
* 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|.
*
* @param {!HTMLMediaElement} mediaElement
* @param {shaka.extern.StreamingConfiguration} config
* @param {function(!Event)} onEvent
* Called when an event is raised to be sent to the application.
* @return {shaka.media.StallDetector}
* @private
*/
createStallDetector_(mediaElement, config, onEvent) {
if (!config.stallEnabled) {
return null;
}
// Cache the values from the config so that changes to the config won't
// change the initialized behaviour.
const threshold = config.stallThreshold;
const skip = config.stallSkip;
// 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(mediaElement),
threshold, onEvent);
detector.onStall((at, duration) => {
shaka.log.debug(`Stall detected at ${at} for ${duration} seconds.`);
if (skip) {
shaka.log.debug(`Seeking forward ${skip} seconds to break stall.`);
mediaElement.currentTime += skip;
} else {
shaka.log.debug('Pausing and unpausing to break stall.');
mediaElement.pause();
mediaElement.play();
}
});
return detector;
}
};