/**
* @license
* Copyright 2016 Google Inc.
*
* 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.
*/
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.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.
*
* @param {!HTMLMediaElement} video
* @param {function()} onSeek Called when the video seeks.
* @param {number} startTime The time to start at.
*
* @constructor
* @struct
* @implements {shaka.util.IReleasable}
*/
shaka.media.VideoWrapper = function(video, onSeek, startTime) {
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {function()} */
this.onSeek_ = onSeek;
/** @private {number} */
this.startTime_ = startTime;
/** @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.
if (video.readyState > 0) {
this.setStartTime_(startTime);
} else {
this.delaySetStartTime_(startTime);
}
};
/** @override */
shaka.media.VideoWrapper.prototype.release = function() {
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}
*/
shaka.media.VideoWrapper.prototype.getTime = function() {
return this.video_.readyState > 0 ?
this.video_.currentTime :
this.startTime_;
};
/**
* Sets the current time of the video.
*
* @param {number} time
*/
shaka.media.VideoWrapper.prototype.setTime = function(time) {
if (this.video_.readyState > 0) {
this.mover_.moveTo(time);
} else {
this.delaySetStartTime_(time);
}
};
/**
* If the media element is not ready, we can't set |currentTime|. To work around
* this we will listen for the "loadedmetadata" event so that we can set the
* start time once the element is ready.
*
* @param {number} startTime
*/
shaka.media.VideoWrapper.prototype.delaySetStartTime_ = function(startTime) {
const readyEvent = 'loadedmetadata';
// Since we are going to override what the start time should be, we need to
// save it so that |getTime| can return the most accurate start time possible.
this.startTime_ = startTime;
// The media element is not ready to accept changes to current time. We need
// to cache them and then execute them once the media element is ready.
this.eventManager_.unlisten(this.video_, readyEvent);
this.eventManager_.listenOnce(this.video_, readyEvent, () => {
this.setStartTime_(startTime);
});
};
/**
* 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
*/
shaka.media.VideoWrapper.prototype.setStartTime_ = function(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. 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/google/shaka-player/issues/1298
this.mover_.moveTo(
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
*/
shaka.media.VideoWrapper.prototype.startListeningToSeeks_ = function() {
goog.asserts.assert(
this.video_.readyState > 0,
'The media element should be ready before we listen for seeking.');
this.eventManager_.listen(this.video_, 'seeking', () => this.onSeek_());
};
/**
* A class used to move the playhead away from its current time. Sometimes, IE
* and Edge ignore 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:
* IE - 0ms - 47%
* IE - 100ms - 63%
* Edge - 0ms - 2%
* Edge - 100ms - 40%
* Edge - 200ms - 32%
* Edge - 300ms - 24%
* Edge - 400ms - 2%
* Chrome - 0ms - 100%
*
* TODO: File a bug on IE/Edge about this.
*
* @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.timer_.stop();
return;
}
// Sigh... Try again...
this.mediaElement_.currentTime = this.targetTime_;
this.remainingAttempts_--;
}
};