/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.media.VideoWrapper');
goog.provide('shaka.media.VideoWrapper.PlayheadMover');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.MediaReadyState');
goog.require('shaka.util.Timer');
/**
* Creates a new VideoWrapper that manages setting current time and playback
* rate. This handles seeks before content is loaded and ensuring the video
* time is set properly. This doesn't handle repositioning within the
* presentation window.
*
* @implements {shaka.util.IReleasable}
*/
shaka.media.VideoWrapper = class {
/**
* @param {!HTMLMediaElement} video
* @param {function()} onSeek Called when the video seeks.
* @param {function(number)} onStarted Called when the video has started.
* @param {function():number} getStartTime Calle to get the time to start at.
*/
constructor(video, onSeek, onStarted, getStartTime) {
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {function()} */
this.onSeek_ = onSeek;
/** @private {function(number)} */
this.onStarted_ = onStarted;
/** @private {?number} */
this.startTime_ = null;
/** @private {function():number} */
this.getStartTime_ = () => {
if (this.startTime_ == null) {
this.startTime_ = getStartTime();
}
return this.startTime_;
};
/** @private {boolean} */
this.started_ = false;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {shaka.media.VideoWrapper.PlayheadMover} */
this.mover_ = new shaka.media.VideoWrapper.PlayheadMover(
/* mediaElement= */ video,
/* maxAttempts= */ 10);
// Before we can set the start time, we must check if the video element is
// ready. If the video element is not ready, we cannot set the time. To work
// around this, we will wait for the "loadedmetadata" event which tells us
// that the media element is now ready.
shaka.util.MediaReadyState.waitForReadyState(this.video_,
HTMLMediaElement.HAVE_METADATA,
this.eventManager_,
() => {
this.setStartTime_(this.getStartTime_());
});
}
/** @override */
release() {
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
if (this.mover_ != null) {
this.mover_.release();
this.mover_ = null;
}
this.onSeek_ = () => {};
this.video_ = null;
}
/**
* Gets the video's current (logical) position.
*
* @return {number}
*/
getTime() {
return this.started_ ? this.video_.currentTime : this.getStartTime_();
}
/**
* Sets the current time of the video.
*
* @param {number} time
*/
setTime(time) {
if (this.video_.readyState > 0) {
this.mover_.moveTo(time);
} else {
shaka.util.MediaReadyState.waitForReadyState(this.video_,
HTMLMediaElement.HAVE_METADATA,
this.eventManager_,
() => {
this.setStartTime_(this.getStartTime_());
});
}
}
/**
* Set the start time for the content. The given start time will be ignored if
* the content does not start at 0.
*
* @param {number} startTime
* @private
*/
setStartTime_(startTime) {
// If we start close enough to our intended start time, then we won't do
// anything special.
if (Math.abs(this.video_.currentTime - startTime) < 0.001) {
this.startListeningToSeeks_();
return;
}
// We will need to delay adding our normal seeking listener until we have
// seen the first seek event. We will force the first seek event later in
// this method.
this.eventManager_.listenOnce(this.video_, 'seeking', () => {
this.startListeningToSeeks_();
});
// If the currentTime != 0, it indicates that the user has seeked after
// calling |Player.load|, meaning that |currentTime| is more meaningful than
// |startTime|.
//
// Seeking to the current time is a work around for Issue 1298 and 4888.
// If we don't do this, the video may get stuck and not play.
//
// TODO: Need further investigation why it happens. Before and after
// setting the current time, video.readyState is 1, video.paused is true,
// and video.buffered's TimeRanges length is 0.
// See: https://github.com/shaka-project/shaka-player/issues/1298
this.mover_.moveTo(
(!this.video_.currentTime || this.video_.currentTime == 0) ?
startTime :
this.video_.currentTime);
}
/**
* Add the listener for seek-events. This will call the externally-provided
* |onSeek| callback whenever the media element seeks.
*
* @private
*/
startListeningToSeeks_() {
goog.asserts.assert(
this.video_.readyState > 0,
'The media element should be ready before we listen for seeking.');
// Now that any startup seeking is complete, we can trust the video element
// for currentTime.
this.started_ = true;
this.eventManager_.listen(this.video_, 'seeking', () => this.onSeek_());
this.onStarted_(this.video_.currentTime);
}
};
/**
* A class used to move the playhead away from its current time. Sometimes,
* legacy Edge ignores re-seeks. After changing the current time, check every
* 100ms, retrying if the change was not accepted.
*
* Delay stats over 100 runs of a re-seeking integration test:
* Edge - 0ms - 2%
* Edge - 100ms - 40%
* Edge - 200ms - 32%
* Edge - 300ms - 24%
* Edge - 400ms - 2%
* Chrome - 0ms - 100%
*
* Unfortunately, legacy Edge is not receiving updates anymore, but it still
* must be supported as it is used for web browsers on XBox.
*
* @implements {shaka.util.IReleasable}
* @final
*/
shaka.media.VideoWrapper.PlayheadMover = class {
/**
* @param {!HTMLMediaElement} mediaElement
* The media element that the mover can manipulate.
*
* @param {number} maxAttempts
* To prevent us from infinitely trying to change the current time, the
* mover accepts a max attempts value. At most, the mover will check if the
* video moved |maxAttempts| times. If this is zero of negative, no
* attempts will be made.
*/
constructor(mediaElement, maxAttempts) {
/** @private {HTMLMediaElement} */
this.mediaElement_ = mediaElement;
/** @private {number} */
this.maxAttempts_ = maxAttempts;
/** @private {number} */
this.remainingAttempts_ = 0;
/** @private {number} */
this.originTime_ = 0;
/** @private {number} */
this.targetTime_ = 0;
/** @private {shaka.util.Timer} */
this.timer_ = new shaka.util.Timer(() => this.onTick_());
}
/** @override */
release() {
if (this.timer_) {
this.timer_.stop();
this.timer_ = null;
}
this.mediaElement_ = null;
}
/**
* Try forcing the media element to move to |timeInSeconds|. If a previous
* call to |moveTo| is still in progress, this will override it.
*
* @param {number} timeInSeconds
*/
moveTo(timeInSeconds) {
this.originTime_ = this.mediaElement_.currentTime;
this.targetTime_ = timeInSeconds;
this.remainingAttempts_ = this.maxAttempts_;
// Set the time and then start the timer. The timer will check if the set
// was successful, and retry if not.
this.mediaElement_.currentTime = timeInSeconds;
this.timer_.tickEvery(/* seconds= */ 0.1);
}
/**
* @private
*/
onTick_() {
// Sigh... We ran out of retries...
if (this.remainingAttempts_ <= 0) {
shaka.log.warning([
'Failed to move playhead from', this.originTime_,
'to', this.targetTime_,
].join(' '));
this.timer_.stop();
return;
}
// Yay! We were successful.
if (this.mediaElement_.currentTime != this.originTime_ ||
this.mediaElement_.currentTime === this.targetTime_) {
this.timer_.stop();
return;
}
// Sigh... Try again...
this.mediaElement_.currentTime = this.targetTime_;
this.remainingAttempts_--;
}
};