mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-16 16:16:40 +03:00
3f822d8122
Closes #3518
516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
/*! @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.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 {
|
|
/**
|
|
* Set the start time. If the content has already started playback, this will
|
|
* be ignored.
|
|
*
|
|
* @param {number} startTime
|
|
*/
|
|
setStartTime(startTime) {}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 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.started_ = true;
|
|
} else {
|
|
// Startup is complete only when the video element acknowledges the
|
|
// seek.
|
|
this.eventManager_.listenOnce(this.mediaElement_, 'seeking', () => {
|
|
this.started_ = true;
|
|
});
|
|
const currentTime = this.mediaElement_.currentTime;
|
|
// Using the currentTime allows using a negative number in Live HLS
|
|
const newTime = Math.max(0, currentTime + this.startTime_);
|
|
this.mediaElement_.currentTime = newTime;
|
|
}
|
|
};
|
|
|
|
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 */
|
|
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 {number} */
|
|
this.minBufferTime_ = manifest.minBufferTime || 0;
|
|
|
|
/** @private {?shaka.extern.StreamingConfiguration} */
|
|
this.config_ = config;
|
|
|
|
/** @private {function()} */
|
|
this.onSeek_ = onSeek;
|
|
|
|
/** @private {?number} */
|
|
this.lastCorrectiveSeek_ = null;
|
|
|
|
/** @private {shaka.media.GapJumpingController} */
|
|
this.gapController_ = new shaka.media.GapJumpingController(
|
|
mediaElement,
|
|
manifest.presentationTimeline,
|
|
config,
|
|
this.createStallDetector_(mediaElement, config),
|
|
onEvent);
|
|
|
|
/** @private {shaka.media.VideoWrapper} */
|
|
this.videoWrapper_ = new shaka.media.VideoWrapper(
|
|
mediaElement,
|
|
() => this.onSeeking_(),
|
|
this.getStartTime_(startTime));
|
|
|
|
/** @type {shaka.util.Timer} */
|
|
this.checkWindowTimer_ = new shaka.util.Timer(() => {
|
|
this.onPollWindow_();
|
|
}).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/google/shaka-player/issues/1121
|
|
if (this.mediaElement_.readyState > 0 && !this.mediaElement_.paused) {
|
|
return this.clampTime_(time);
|
|
}
|
|
|
|
return time;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// 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;
|
|
if (!this.lastCorrectiveSeek_ || this.lastCorrectiveSeek_ < time - 1) {
|
|
this.lastCorrectiveSeek_ = time;
|
|
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/google/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 = Math.max(
|
|
this.minBufferTime_,
|
|
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.');
|
|
return end;
|
|
}
|
|
|
|
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
|
|
* @return {shaka.media.StallDetector}
|
|
* @private
|
|
*/
|
|
createStallDetector_(mediaElement, config) {
|
|
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);
|
|
|
|
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;
|
|
}
|
|
};
|