UNPKG

shaka-player

Version:
724 lines (609 loc) 23.1 kB
/** * @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.Playhead'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IDestroyable'); goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.Timer'); /** * 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. * * @param {HTMLMediaElement} video * @param {shakaExtern.Manifest} manifest * @param {shakaExtern.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 * @struct * @implements {shaka.util.IDestroyable} */ shaka.media.Playhead = function( video, manifest, config, startTime, onSeek, onEvent) { /** @private {HTMLMediaElement} */ this.video_ = video; /** @private {?shakaExtern.Manifest} */ this.manifest_ = manifest; /** @private {?shakaExtern.StreamingConfiguration} */ this.config_ = config; /** * The playhead's initial position in seconds, or null if it should * automatically be calculated later. * @private {?number} */ this.startTime_ = startTime == null ? null : this.clampSeekToDuration_(startTime); /** @private {?function()} */ this.onSeek_ = onSeek; /** @private {?function(!Event)} */ this.onEvent_ = onEvent; /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); /** @private {boolean} */ this.buffering_ = false; /** @private {number} */ this.playbackRate_ = 1; /** @private {?shaka.util.Timer} */ this.trickPlayTimer_ = null; /** @private {?shaka.util.Timer} */ this.gapJumpTimer_ = null; /** * Used to batch up early seeks and delay until video.currentTime is updated. * * @private {shaka.util.Timer} */ this.earlySeekTimer_ = new shaka.util.Timer(this.onEarlySeek_.bind(this)); /** @private {number} */ this.prevReadyState_ = video.readyState; /** @private {boolean} */ this.didFireLargeGap_ = false; /** * The wall-clock time (in milliseconds) that the stall occurred. This is * used to ensure we don't flush the pipeline too often. * @private {number} */ this.stallWallTime_ = -1; /** * The playhead time where we think a stall occurred. When the ready state * says we don't have enough data and the playhead stops too long, we assume * we have stalled. * @private {number} */ this.stallPlayheadTime_ = -1; /** * True if we have already flushed the pipeline at stallPlayheadTime_. * Allows us to avoid flushing multiple times for the same stall. * @private {boolean} */ this.stallCorrected_ = false; /** @private {boolean} */ this.hadSegmentAppended_ = false; // Check if the video has already loaded some metadata. if (video.readyState > 0) { this.onLoadedMetadata_(); } else { this.eventManager_.listenOnce( video, 'loadedmetadata', this.onLoadedMetadata_.bind(this)); // Check for early seeks before we have any media loaded. // See onEarlySeek_() for details. this.eventManager_.listen(video, 'timeupdate', function() { // In practice, the value of video.currentTime doesn't change before the // event is fired. Delay 100ms and batch up changes. this.earlySeekTimer_.schedule(0.1 /* seconds */); }.bind(this)); } var pollGap = this.onPollGapJump_.bind(this); this.eventManager_.listen(video, 'ratechange', this.onRateChange_.bind(this)); this.eventManager_.listen(video, 'waiting', pollGap); // We can't trust readyState or 'waiting' events on all platforms. So poll // the current time and if we are in a gap, jump it. // See: https://goo.gl/sbSHp9 and https://goo.gl/cuAcYd this.gapJumpTimer_ = new shaka.util.Timer(pollGap); this.gapJumpTimer_.scheduleRepeated(0.25); }; /** @override */ shaka.media.Playhead.prototype.destroy = function() { var p = this.eventManager_.destroy(); this.eventManager_ = null; if (this.trickPlayTimer_ != null) { this.trickPlayTimer_.cancel(); this.trickPlayTimer_ = null; } if (this.gapJumpTimer_ != null) { this.gapJumpTimer_.cancel(); this.gapJumpTimer_ = null; } if (this.earlySeekTimer_ != null) { this.earlySeekTimer_.cancel(); this.earlySeekTimer_ = null; } this.video_ = null; this.manifest_ = null; this.config_ = null; this.onSeek_ = null; this.onEvent_ = null; return p; }; /** * Adjust the start time. Used by Player to implement the * streaming.startAtSegmentBoundary configuration. * * @param {number} startTime */ shaka.media.Playhead.prototype.setStartTime = function(startTime) { if (this.video_.readyState > 0) this.video_.currentTime = this.clampTime_(startTime); else this.startTime_ = startTime; }; /** * 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. * * TODO: Clean up and simplify Playhead. There are too many layers of, methods * for, and conditions on timestamp adjustment. * * @see https://github.com/google/shaka-player/issues/979 * @param {number} time * @return {number} The adjusted seek time. * @private */ shaka.media.Playhead.prototype.clampSeekToDuration_ = function(time) { var timeline = this.manifest_.presentationTimeline; var duration = 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; }; /** * Gets the playhead's current (logical) position. * * @return {number} */ shaka.media.Playhead.prototype.getTime = function() { if (this.video_.readyState > 0) { // 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.video_.paused) return this.video_.currentTime; // Although we restrict the video's currentTime elsewhere, clamp it here to // ensure any timing issues (e.g., the user agent seeks and calls this // function before we receive the 'seeking' event) don't cause us to return // a time outside the segment availability window. return this.clampTime_(this.video_.currentTime); } return this.getStartTime_(); }; /** * Gets the playhead's initial position in seconds. * * @return {number} * @private */ shaka.media.Playhead.prototype.getStartTime_ = function() { if (this.startTime_ != null) { return this.clampTime_(this.startTime_); } var startTime; var timeline = this.manifest_.presentationTimeline; if (timeline.getDuration() < Infinity) { // If the presentation is VOD, or if the presentation is live but has // finished broadcasting, then start from the beginning. startTime = timeline.getSegmentAvailabilityStart(); } else { // Otherwise, start near the live-edge. startTime = timeline.getSeekRangeEnd(); } // If we don't set this, getStartTime_ will continue to wander forward. For // a drifting stream, this is very bad, as we will never fall back into a // playable range. // TODO: re-evaluate after #999 (drift tolerance refactor) is resolved this.startTime_ = this.clampSeekToDuration_(startTime); return startTime; }; /** * Stops the playhead for buffering, or resumes the playhead after buffering. * * @param {boolean} buffering True to stop the playhead; false to allow it to * continue. */ shaka.media.Playhead.prototype.setBuffering = function(buffering) { if (buffering != this.buffering_) { this.buffering_ = buffering; this.setPlaybackRate(this.playbackRate_); } }; /** * Gets the current effective playback rate. This may be negative even if the * browser does not directly support rewinding. * @return {number} */ shaka.media.Playhead.prototype.getPlaybackRate = function() { return this.playbackRate_; }; /** * Sets the playback rate. * @param {number} rate */ shaka.media.Playhead.prototype.setPlaybackRate = function(rate) { if (this.trickPlayTimer_ != null) { this.trickPlayTimer_.cancel(); this.trickPlayTimer_ = null; } this.playbackRate_ = rate; // All major browsers support playback rates above zero. Only need fake // trick play for negative rates. this.video_.playbackRate = (this.buffering_ || rate < 0) ? 0 : rate; if (!this.buffering_ && rate < 0) { // Defer creating the timer until we stop buffering. This function will be // called again from setBuffering(). var trickPlay = (function() { this.video_.currentTime += rate / 4; }.bind(this)); this.trickPlayTimer_ = new shaka.util.Timer(trickPlay); this.trickPlayTimer_.scheduleRepeated(0.25); } }; /** * 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. */ shaka.media.Playhead.prototype.onSegmentAppended = function() { this.hadSegmentAppended_ = true; this.onPollGapJump_(); }; /** * Handles a 'ratechange' event. * * @private */ shaka.media.Playhead.prototype.onRateChange_ = function() { // NOTE: This will not allow explicitly setting the playback rate to 0 while // the playback rate is negative. Pause will still work. var expectedRate = this.buffering_ || this.playbackRate_ < 0 ? 0 : this.playbackRate_; // Native controls in Edge trigger a change to playbackRate and set it to 0 // when seeking. If we don't exclude 0 from this check, we will force the // rate to stay at 0 after a seek with Edge native controls. // https://github.com/google/shaka-player/issues/951 if (this.video_.playbackRate && this.video_.playbackRate != expectedRate) { shaka.log.debug('Video playback rate changed to', this.video_.playbackRate); this.setPlaybackRate(this.video_.playbackRate); } }; /** * Handles a 'loadedmetadata' event. * * @private */ shaka.media.Playhead.prototype.onLoadedMetadata_ = function() { // Move the real playhead to the start time. var targetTime = this.getStartTime_(); // We are out of the phase in which "early seek" may occur, so stop listening // for the 'timeupdate' event. this.eventManager_.unlisten(this.video_, 'timeupdate'); // Cancel any pending early seek timer. Cancelation is important because it // resolves a race between the first "timeupdate" and the "loadedmetadata" // event. On some browsers, "timeupdate" fires right before "loadedmetadata", // which would cause earlySeekTimer_ to fire inappropriately and trigger a // failed assertion. this.earlySeekTimer_.cancel(); if (Math.abs(this.video_.currentTime - targetTime) < 0.001) { this.eventManager_.listen( this.video_, 'seeking', this.onSeeking_.bind(this)); this.eventManager_.listen( this.video_, 'playing', this.onPlaying_.bind(this)); } else { this.eventManager_.listenOnce( this.video_, 'seeking', this.onSeekingToStartTime_.bind(this)); this.video_.currentTime = targetTime; } }; /** * Handles the 'seeking' event from the initial jump to the start time (if * there is one). * * @private */ shaka.media.Playhead.prototype.onSeekingToStartTime_ = function() { goog.asserts.assert(this.video_.readyState > 0, 'readyState should be greater than 0'); this.eventManager_.listen(this.video_, 'seeking', this.onSeeking_.bind(this)); this.eventManager_.listen(this.video_, 'playing', this.onPlaying_.bind(this)); }; /** * Called on a recurring timer to check for gaps in the media. This is also * called in a 'waiting' event. * * @private */ shaka.media.Playhead.prototype.onPollGapJump_ = function() { // Don't gap jump before the video is ready to play. if (this.video_.readyState == 0) return; // Don't gap jump while paused, so that you don't constantly jump ahead while // paused on a livestream. if (this.video_.paused) 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.didFireLargeGap_ = false; this.prevReadyState_ = this.video_.readyState; } var smallGapLimit = this.config_.smallGapLimit; var currentTime = this.video_.currentTime; var buffered = this.video_.buffered; // If seeking is not possible, clamp the playhead manually here. var timeline = this.manifest_.presentationTimeline; var availabilityStart = timeline.getSegmentAvailabilityStart(); if (currentTime < availabilityStart) { // The availability window has moved past the playhead. // Move ahead to catch up. var targetTime = this.reposition_(currentTime); shaka.log.info('Jumping forward ' + (targetTime - currentTime) + ' seconds to catch up with the availability window.'); this.movePlayhead_(currentTime, targetTime); return; } var gapIndex = shaka.media.TimeRangesUtils.getGapIndex(buffered, currentTime); // The current time is unbuffered or is too far from a gap. if (gapIndex == null) { if (this.video_.readyState < 3 && this.video_.playbackRate > 0) { // Some platforms/browsers can get stuck in the middle of a buffered range // (e.g. when seeking in a background tab). Flush the media pipeline to // help. // // Flush once we have stopped for more than 1 second inside a buffered // range. Note that Chromecast takes a few seconds to start playing // after any kind of seek, so wait 5 seconds between repeated flushes. if (this.stallPlayheadTime_ != currentTime) { this.stallPlayheadTime_ = currentTime; this.stallWallTime_ = Date.now(); this.stallCorrected_ = false; } else if (!this.stallCorrected_ && this.stallWallTime_ < Date.now() - 1000) { for (var i = 0; i < buffered.length; i++) { // Ignore the end of the buffered range since it may not play any more // on all platforms. if (currentTime >= buffered.start(i) && currentTime < buffered.end(i) - 0.5) { shaka.log.debug( 'Flushing media pipeline due to stall inside buffered range'); this.video_.currentTime += 0.1; this.stallPlayheadTime_ = this.video_.currentTime; this.stallCorrected_ = true; break; } } } } 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. var jumpTo = buffered.start(gapIndex); var seekEnd = this.manifest_.presentationTimeline.getSeekRangeEnd(); if (jumpTo >= seekEnd) return; var jumpSize = jumpTo - currentTime; var isGapSmall = jumpSize <= smallGapLimit; var jumpLargeGap = false; if (!isGapSmall && !this.didFireLargeGap_) { this.didFireLargeGap_ = true; // Event firing is synchronous. var event = new shaka.util.FakeEvent( 'largegap', {'currentTime': currentTime, 'gapSize': jumpSize}); event.cancelable = true; this.onEvent_(event); if (this.config_.jumpLargeGaps && !event.defaultPrevented) jumpLargeGap = true; else shaka.log.info('Ignoring large gap at', currentTime); } if (isGapSmall || jumpLargeGap) { 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.movePlayhead_(currentTime, jumpTo); } }; /** * Handles a 'timeupdate' event that occurs before metadata is loaded, which * would indicate that the user is seeking. * * Note that a 'seeking' event will not fire before content is loaded. In this * state, the playhead can only move as a result of a seek action, so timeupdate * is a good choice. * * @private */ shaka.media.Playhead.prototype.onEarlySeek_ = function() { goog.asserts.assert(this.video_.readyState == 0, 'readyState should be 0 for early seeking'); var currentTime = this.video_.currentTime; var targetTime = this.reposition_(currentTime); shaka.log.v1('Early seek to', currentTime, 'remapped to', targetTime); this.startTime_ = targetTime; }; /** * Handles a 'seeking' event. * * @private */ shaka.media.Playhead.prototype.onSeeking_ = function() { goog.asserts.assert(this.video_.readyState > 0, 'readyState should be greater than 0'); this.hadSegmentAppended_ = false; var currentTime = this.video_.currentTime; var targetTime = this.reposition_(currentTime); if (Math.abs(targetTime - currentTime) > 0.001) { this.movePlayhead_(currentTime, targetTime); return; } shaka.log.v1('Seek to ' + currentTime); this.didFireLargeGap_ = false; this.onSeek_(); }; /** * Handles a 'playing' event. * * @private */ shaka.media.Playhead.prototype.onPlaying_ = function() { goog.asserts.assert(this.video_.readyState > 0, 'readyState should be greater than 0'); var currentTime = this.video_.currentTime; var targetTime = this.reposition_(currentTime); if (Math.abs(targetTime - currentTime) > 0.001) this.movePlayhead_(currentTime, targetTime); }; /** * Computes a new playhead position that's within the presentation timeline. * * @param {number} currentTime * @return {number} The time to reposition the playhead to. * @private */ shaka.media.Playhead.prototype.reposition_ = function(currentTime) { goog.asserts.assert(this.manifest_ && this.config_, 'Must not be destroyed'); /** @type {function(number)} */ var isBuffered = shaka.media.TimeRangesUtils.isBuffered.bind(null, this.video_.buffered); var rebufferingGoal = shaka.util.StreamUtils.getRebufferingGoal( this.manifest_, this.config_, 1 /* scaleFactor */); var timeline = this.manifest_.presentationTimeline; var start = timeline.getSafeAvailabilityStart(0); var end = timeline.getSegmentAvailabilityEnd(); var duration = timeline.getDuration(); // 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|. var safe = timeline.getSafeAvailabilityStart(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. // // Offset by 5 seconds since Chromecast takes a few seconds to start playing // after a seek, even when buffered. var seekStart = timeline.getSafeAvailabilityStart(5); var seekSafe = timeline.getSafeAvailabilityStart(rebufferingGoal + 5); 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; } }; /** * Moves the playhead to the target time, triggering a call to onSeeking_(). * * @param {number} currentTime * @param {number} targetTime * @private */ shaka.media.Playhead.prototype.movePlayhead_ = function( currentTime, targetTime) { shaka.log.debug('Moving playhead...', 'currentTime=' + currentTime, 'targetTime=' + targetTime); this.video_.currentTime = targetTime; // Sometimes, IE and Edge ignore re-seeks. Check every 100ms and try // again if need be, up to 10 tries. // 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. var tries = 0; var recheck = (function() { if (!this.video_) return; if (tries++ >= 10) return; if (this.video_.currentTime == currentTime) { // Sigh. Try again. this.video_.currentTime = targetTime; setTimeout(recheck, 100); } }).bind(this); setTimeout(recheck, 100); }; /** * Clamps the given time to the segment availability window. * * @param {number} time The time in seconds. * @return {number} The clamped time in seconds. * @private */ shaka.media.Playhead.prototype.clampTime_ = function(time) { var start = this.manifest_.presentationTimeline.getSegmentAvailabilityStart(); if (time < start) return start; var end = this.manifest_.presentationTimeline.getSegmentAvailabilityEnd(); if (time > end) return end; return time; };