From 917a01df5b3738af2bcd681abd80c2a784047749 Mon Sep 17 00:00:00 2001 From: Jacob Trimble Date: Tue, 13 Feb 2018 16:30:49 -0800 Subject: [PATCH] Split Playhead responsibilities. This splits some of the independent code from Playhead into several new helper classes. This allows Playhead to be simpler and easier to understand. This keeps the new behaviors and classes as private pieces of Playhead to keep Playhead conceptually in-charge of handing the Playhead. It still has the same responsibilities, but the code is split into other files. Issue #1224 Change-Id: Ia828f902ba9490d128f4ca9cb1e34119ec93f188 --- build/types/core | 2 + lib/media/gap_jumping_controller.js | 267 ++++++++++++++ lib/media/playhead.js | 534 +++++----------------------- lib/media/video_wrapper.js | 310 ++++++++++++++++ lib/player.js | 1 + test/media/playhead_unit.js | 5 +- 6 files changed, 664 insertions(+), 455 deletions(-) create mode 100644 lib/media/gap_jumping_controller.js create mode 100644 lib/media/video_wrapper.js diff --git a/build/types/core b/build/types/core index fe2d4311bc..3a61689f34 100644 --- a/build/types/core +++ b/build/types/core @@ -9,6 +9,7 @@ +../../lib/debug/log.js +../../lib/media/drm_engine.js ++../../lib/media/gap_jumping_controller.js +../../lib/media/manifest_parser.js +../../lib/media/media_source_engine.js +../../lib/media/mp4_segment_index_parser.js @@ -20,6 +21,7 @@ +../../lib/media/streaming_engine.js +../../lib/media/time_ranges_utils.js +../../lib/media/transmuxer.js ++../../lib/media/video_wrapper.js +../../lib/media/webm_segment_index_parser.js +../../lib/net/backoff.js diff --git a/lib/media/gap_jumping_controller.js b/lib/media/gap_jumping_controller.js new file mode 100644 index 0000000000..00d241f328 --- /dev/null +++ b/lib/media/gap_jumping_controller.js @@ -0,0 +1,267 @@ +/** + * @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.GapJumpingController'); + +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.Timer'); + + + +/** + * Creates a new GapJumpingController that handles jumping gaps that appear + * within the content. This will only jump gaps between two buffered ranges, + * so we should not have to worry about the availability window. + * + * @param {!HTMLMediaElement} video + * @param {shakaExtern.Manifest} manifest + * @param {shakaExtern.StreamingConfiguration} config + * @param {function(!Event)} onEvent Called when an event is raised to be sent + * to the application. + * + * @constructor + * @struct + * @implements {shaka.util.IDestroyable} + */ +shaka.media.GapJumpingController = function(video, manifest, config, onEvent) { + /** @private {HTMLMediaElement} */ + this.video_ = video; + + /** @private {?shakaExtern.Manifest} */ + this.manifest_ = manifest; + + /** @private {?shakaExtern.StreamingConfiguration} */ + this.config_ = config; + + /** @private {?function(!Event)} */ + this.onEvent_ = onEvent; + + /** @private {shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @private {?shaka.util.Timer} */ + this.gapJumpTimer_ = null; + + /** @private {boolean} */ + this.seekingEventReceived_ = false; + + /** @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; + + + let pollGap = this.onPollGapJump_.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.GapJumpingController.prototype.destroy = function() { + let p = this.eventManager_.destroy(); + this.eventManager_ = null; + this.video_ = null; + this.manifest_ = null; + this.onEvent_ = null; + + if (this.gapJumpTimer_ != null) { + this.gapJumpTimer_.cancel(); + this.gapJumpTimer_ = null; + } + + return p; +}; + + +/** + * 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.GapJumpingController.prototype.onSegmentAppended = function() { + this.hadSegmentAppended_ = true; + this.onPollGapJump_(); +}; + + +/** Called when a seek has started. */ +shaka.media.GapJumpingController.prototype.onSeeking = function() { + this.seekingEventReceived_ = true; + this.hadSegmentAppended_ = false; + this.didFireLargeGap_ = false; +}; + + +/** + * Called on a recurring timer to check for gaps in the media. This is also + * called in a 'waiting' event. + * + * @private + */ +shaka.media.GapJumpingController.prototype.onPollGapJump_ = function() { + // Don't gap jump before the video is ready to play. + if (this.video_.readyState == 0) return; + // Do not gap jump if seeking has begun, but the seeking event has not + // yet fired for this particular seek. + if (this.video_.seeking) { + if (!this.seekingEventReceived_) + return; + } else { + this.seekingEventReceived_ = false; + } + // 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; + } + + const smallGapLimit = this.config_.smallGapLimit; + let currentTime = this.video_.currentTime; + let buffered = this.video_.buffered; + + let gapIndex = shaka.media.TimeRangesUtils.getGapIndex(buffered, currentTime); + + // The current time is unbuffered or is too far from a gap. + if (gapIndex == null) { + this.handleStall_(); + 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. + let jumpTo = buffered.start(gapIndex); + let seekEnd = this.manifest_.presentationTimeline.getSeekRangeEnd(); + if (jumpTo >= seekEnd) + return; + + let jumpSize = jumpTo - currentTime; + let isGapSmall = jumpSize <= smallGapLimit; + let jumpLargeGap = false; + + if (!isGapSmall && !this.didFireLargeGap_) { + this.didFireLargeGap_ = true; + + // Event firing is synchronous. + let 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.video_.currentTime = jumpTo; + } +}; + + +/** + * This determines if we are stalled inside a buffered range and corrects it if + * possible. + * @private + */ +shaka.media.GapJumpingController.prototype.handleStall_ = function() { + let currentTime = this.video_.currentTime; + let buffered = this.video_.buffered; + + 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. + if (this.stallPlayheadTime_ != currentTime) { + this.stallPlayheadTime_ = currentTime; + this.stallWallTime_ = Date.now(); + this.stallCorrected_ = false; + } else if (!this.stallCorrected_ && + this.stallWallTime_ < Date.now() - 1000) { + for (let 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; + } + } + } + } +}; diff --git a/lib/media/playhead.js b/lib/media/playhead.js index 8fff136002..3110a57682 100644 --- a/lib/media/playhead.js +++ b/lib/media/playhead.js @@ -19,9 +19,9 @@ goog.provide('shaka.media.Playhead'); goog.require('goog.asserts'); goog.require('shaka.log'); +goog.require('shaka.media.GapJumpingController'); goog.require('shaka.media.TimeRangesUtils'); -goog.require('shaka.util.EventManager'); -goog.require('shaka.util.FakeEvent'); +goog.require('shaka.media.VideoWrapper'); goog.require('shaka.util.IDestroyable'); goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.Timer'); @@ -35,7 +35,7 @@ goog.require('shaka.util.Timer'); * restricting seeking to valid time ranges, and stopping playback for startup * and re- buffering. * - * @param {HTMLMediaElement} video + * @param {!HTMLMediaElement} video * @param {shakaExtern.Manifest} manifest * @param {shakaExtern.StreamingConfiguration} config * @param {?number} startTime The playhead's initial position in seconds. If @@ -61,130 +61,45 @@ shaka.media.Playhead = function( /** @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; + this.checkWindowTimer_ = null; - /** @private {?shaka.util.Timer} */ - this.gapJumpTimer_ = null; - - /** @private {boolean} */ - this.seekingEventReceived_ = false; - - /** - * 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)); - } + /** @private {shaka.media.GapJumpingController} */ + this.gapController_ = + new shaka.media.GapJumpingController(video, manifest, config, onEvent); + + /** @private {shaka.media.VideoWrapper} */ + this.videoWrapper_ = new shaka.media.VideoWrapper( + video, this.onSeeking_.bind(this), this.getStartTime_(startTime)); - 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); + let poll = this.onPollWindow_ .bind(this); + this.checkWindowTimer_ = new shaka.util.Timer(poll); + this.checkWindowTimer_.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; + var p = Promise.all([ + this.videoWrapper_.destroy(), + this.gapController_.destroy(), + ]); + this.videoWrapper_ = null; + this.gapController_ = null; + + if (this.checkWindowTimer_ != null) { + this.checkWindowTimer_.cancel(); + this.checkWindowTimer_ = null; } this.video_ = null; this.manifest_ = null; this.config_ = null; this.onSeek_ = null; - this.onEvent_ = null; return p; }; @@ -197,35 +112,7 @@ shaka.media.Playhead.prototype.destroy = function() { * @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; + this.videoWrapper_.setTime(startTime); }; @@ -235,53 +122,46 @@ shaka.media.Playhead.prototype.clampSeekToDuration_ = function(time) { * @return {number} */ shaka.media.Playhead.prototype.getTime = function() { + var time = this.videoWrapper_.getTime(); 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); + if (!this.video_.paused) + time = this.clampTime_(time); } - return this.getStartTime_(); + return time; }; /** * Gets the playhead's initial position in seconds. * + * @param {?number} startTime * @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(); +shaka.media.Playhead.prototype.getStartTime_ = function(startTime) { + if (startTime == null) { + 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; + return this.clampSeekToDuration_(this.clampTime_(startTime)); }; @@ -292,10 +172,7 @@ shaka.media.Playhead.prototype.getStartTime_ = function() { * continue. */ shaka.media.Playhead.prototype.setBuffering = function(buffering) { - if (buffering != this.buffering_) { - this.buffering_ = buffering; - this.setPlaybackRate(this.playbackRate_); - } + this.videoWrapper_.setBuffering(buffering); }; @@ -305,7 +182,7 @@ shaka.media.Playhead.prototype.setBuffering = function(buffering) { * @return {number} */ shaka.media.Playhead.prototype.getPlaybackRate = function() { - return this.playbackRate_; + return this.videoWrapper_.getPlaybackRate(); }; @@ -314,25 +191,7 @@ shaka.media.Playhead.prototype.getPlaybackRate = function() { * @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); - } + this.videoWrapper_.setPlaybackRate(rate); }; @@ -342,268 +201,78 @@ shaka.media.Playhead.prototype.setPlaybackRate = function(rate) { * 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)); + this.gapController_.onSegmentAppended(); }; /** - * Called on a recurring timer to check for gaps in the media. This is also - * called in a 'waiting' event. + * Called on a recurring timer to keep the playhead from falling outside the + * availability window. * * @private */ -shaka.media.Playhead.prototype.onPollGapJump_ = function() { - // Don't gap jump before the video is ready to play. - if (this.video_.readyState == 0) return; - // Do not gap jump if seeking has begun, but the seeking event has not - // yet fired for this particular seek. - if (this.video_.seeking) { - if (!this.seekingEventReceived_) - return; - } else { - this.seekingEventReceived_ = false; - } - // 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; +shaka.media.Playhead.prototype.onPollWindow_ = function() { + // Don't keep in window when there is no content, we are seeking, or we are + // paused. + if (this.video_.readyState == 0 || this.video_.seeking || this.video_.paused) + return; - // If seeking is not possible, clamp the playhead manually here. - var timeline = this.manifest_.presentationTimeline; - var availabilityStart = timeline.getSegmentAvailabilityStart(); + let currentTime = this.video_.currentTime; + let timeline = this.manifest_.presentationTimeline; + let availabilityStart = timeline.getSegmentAvailabilityStart(); if (currentTime < availabilityStart) { // The availability window has moved past the playhead. // Move ahead to catch up. - var targetTime = this.reposition_(currentTime); + let 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); + this.video_.currentTime = targetTime; } }; /** - * 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. + * Handles when a seek happens on the video. * * @private */ shaka.media.Playhead.prototype.onSeeking_ = function() { - goog.asserts.assert(this.video_.readyState > 0, - 'readyState should be greater than 0'); - - this.seekingEventReceived_ = true; - this.hadSegmentAppended_ = false; - var currentTime = this.video_.currentTime; + this.gapController_.onSeeking(); + var currentTime = this.videoWrapper_.getTime(); var targetTime = this.reposition_(currentTime); if (Math.abs(targetTime - currentTime) > 0.001) { - this.movePlayhead_(currentTime, targetTime); + this.videoWrapper_.setTime(targetTime); return; } shaka.log.v1('Seek to ' + currentTime); - this.didFireLargeGap_ = false; this.onSeek_(); }; /** - * Handles a 'playing' event. + * 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.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); +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; }; @@ -676,47 +345,6 @@ shaka.media.Playhead.prototype.reposition_ = function(currentTime) { }; -/** - * 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. * diff --git a/lib/media/video_wrapper.js b/lib/media/video_wrapper.js new file mode 100644 index 0000000000..211a6ece1f --- /dev/null +++ b/lib/media/video_wrapper.js @@ -0,0 +1,310 @@ +/** + * @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.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.IDestroyable'); +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.IDestroyable} + */ +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 {number} */ + this.playbackRate_ = 1; + + /** @private {boolean} */ + this.buffering_ = false; + + /** @private {shaka.util.Timer} */ + this.trickPlayTimer_ = 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)); + + + // 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', () => { + // 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 */); + }); + } + + this.eventManager_.listen(video, 'ratechange', this.onRateChange_.bind(this)); +}; + + +/** @override */ +shaka.media.VideoWrapper.prototype.destroy = function() { + let p = this.eventManager_.destroy(); + this.eventManager_ = null; + + if (this.trickPlayTimer_ != null) { + this.trickPlayTimer_.cancel(); + this.trickPlayTimer_ = null; + } + + if (this.earlySeekTimer_ != null) { + this.earlySeekTimer_.cancel(); + this.earlySeekTimer_ = null; + } + + this.video_ = null; + this.onSeek_ = null; + + return p; +}; + + +/** + * Gets the video's current (logical) position. + * + * @return {number} + */ +shaka.media.VideoWrapper.prototype.getTime = function() { + if (this.video_.readyState > 0) + return this.video_.currentTime; + else + return 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.movePlayhead_(this.video_.currentTime, time); + } else { + this.startTime_ = time; + setTimeout(this.onSeek_, 0); + } +}; + + +/** + * Gets the current effective playback rate. This may be negative even if the + * browser does not directly support rewinding. + * @return {number} + */ +shaka.media.VideoWrapper.prototype.getPlaybackRate = function() { + return this.playbackRate_; +}; + + +/** + * Sets the playback rate. + * @param {number} rate + */ +shaka.media.VideoWrapper.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(). + let trickPlay = () => { this.video_.currentTime += rate / 4; }; + this.trickPlayTimer_ = new shaka.util.Timer(trickPlay); + this.trickPlayTimer_.scheduleRepeated(0.25); + } +}; + + +/** + * 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.VideoWrapper.prototype.setBuffering = function(buffering) { + if (buffering != this.buffering_) { + this.buffering_ = buffering; + this.setPlaybackRate(this.playbackRate_); + } +}; + + +/** + * Handles a 'ratechange' event. + * + * @private + */ +shaka.media.VideoWrapper.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. + let 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.VideoWrapper.prototype.onLoadedMetadata_ = function() { + // 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 - this.startTime_) < 0.001) { + this.onSeekingToStartTime_(); + } else { + this.eventManager_.listenOnce( + this.video_, 'seeking', this.onSeekingToStartTime_.bind(this)); + this.video_.currentTime = this.startTime_; + } +}; + + +/** + * Handles the 'seeking' event from the initial jump to the start time (if + * there is one). + * + * @private + */ +shaka.media.VideoWrapper.prototype.onSeekingToStartTime_ = function() { + goog.asserts.assert(this.video_.readyState > 0, + 'readyState should be greater than 0'); + this.eventManager_.listen(this.video_, 'seeking', () => this.onSeek_()); +}; + + +/** + * 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.VideoWrapper.prototype.onEarlySeek_ = function() { + goog.asserts.assert(this.video_.readyState == 0, + 'readyState should be 0 for early seeking'); + + this.startTime_ = this.video_.currentTime; + this.onSeek_(); +}; + + +/** + * Moves the playhead to the target time, triggering a call to onSeeking_(). + * + * @param {number} currentTime + * @param {number} targetTime + * @private + */ +shaka.media.VideoWrapper.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. + let tries = 0; + let recheck = () => { + if (!this.video_) return; + if (tries++ >= 10) return; + + if (this.video_.currentTime == currentTime) { + // Sigh. Try again. + this.video_.currentTime = targetTime; + setTimeout(recheck, 100); + } + }; + setTimeout(recheck, 100); +}; diff --git a/lib/player.js b/lib/player.js index ff9fb8d289..1664b3557f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -816,6 +816,7 @@ shaka.Player.prototype.createNetworkingEngine = function() { */ shaka.Player.prototype.createPlayhead = function(opt_startTime) { goog.asserts.assert(this.manifest_, 'Must have manifest'); + goog.asserts.assert(this.video_, 'Must have video'); var startTime = opt_startTime == undefined ? null : opt_startTime; return new shaka.media.Playhead( this.video_, this.manifest_, this.config_.streaming, diff --git a/test/media/playhead_unit.js b/test/media/playhead_unit.js index 64cd79e2e4..5a64ce1df9 100644 --- a/test/media/playhead_unit.js +++ b/test/media/playhead_unit.js @@ -573,6 +573,7 @@ describe('Playhead', function() { timeline.getSegmentAvailabilityStart.and.returnValue(1030); timeline.getSegmentAvailabilityEnd.and.returnValue(1030); video.on['waiting'](); + jasmine.clock().tick(500); // We expect this to move to 15 seconds ahead of the start of the // availability window, due to the rebuffering goal (10s) and the 5s // for the Chromecast. @@ -610,7 +611,7 @@ describe('Playhead', function() { // Because this is buffered, the playhead should move to (start + 5), // which will cause a 'seeking' event. - video.on['playing'](); + jasmine.clock().tick(500); expect(video.currentTime).toBe(15); video.on['seeking'](); expect(playhead.getTime()).toBe(15); @@ -641,7 +642,7 @@ describe('Playhead', function() { timeline.getSafeAvailabilityStart.and.returnValue(10); timeline.getSegmentAvailabilityEnd.and.returnValue(70); - video.on['playing'](); + jasmine.clock().tick(500); expect(video.currentTime).toBe(10); video.on['seeking'](); expect(playhead.getTime()).toBe(10);