From 5987458e445cf21f91bf4833396edd63d5f69765 Mon Sep 17 00:00:00 2001 From: Julian Domingo Date: Thu, 2 Jun 2022 13:07:17 -0700 Subject: [PATCH] feat: add listenable events for playback stall detection and gap jumping (#4249) An event `stalldetected` can be dispatched when Shaka Player detects a stall based on the value of stallThreshold through [StreamingConfiguration](https://shaka-player-demo.appspot.com/docs/api/externs_shaka_player.js.html#line920). A second event `gapjumped` could also be dispatched when Shaka performs a jump in a media gap. Related to issue #4227 --- CONTRIBUTORS | 1 + externs/shaka/player.js | 8 ++ lib/cast/cast_proxy.js | 6 +- lib/cast/cast_receiver.js | 6 +- lib/media/gap_jumping_controller.js | 24 +++- lib/media/playhead.js | 51 +++++++- lib/media/stall_detector.js | 21 ++- lib/media/streaming_engine.js | 2 +- lib/player.js | 142 +++++++++++---------- lib/util/fake_event.js | 39 ++++++ lib/util/stats.js | 29 ++++- test/media/playhead_unit.js | 139 ++++++++++++++++---- test/media/stall_detector_unit.js | 22 +++- test/media/streaming_engine_integration.js | 3 +- test/player_integration.js | 3 + test/test/util/simple_fakes.js | 14 ++ test/ui/ui_unit.js | 23 +++- ui/statistics_button.js | 10 ++ 18 files changed, 427 insertions(+), 116 deletions(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 26c5952226..de3998609b 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -66,6 +66,7 @@ John Bowers Jonas Birmé Jono Ward Jozef Chúťka +Julian Domingo Jun Hong Chong Leandro Ribeiro Moreira Lucas Gabriel Sánchez diff --git a/externs/shaka/player.js b/externs/shaka/player.js index aaef33997a..74576f01cc 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -82,6 +82,9 @@ shaka.extern.StateChange; * * maxSegmentDuration: number, * + * gapsJumped: number, + * stallsDetected: number, + * * switchHistory: !Array., * stateHistory: !Array. * }} @@ -112,6 +115,11 @@ shaka.extern.StateChange; * @property {number} estimatedBandwidth * The current estimated network bandwidth (in bit/sec). * + * @property {number} gapsJumped + * The total number of playback gaps jumped by the GapJumpingController. + * @property {number} stallsDetected + * The total number of playback stalls detected by the StallDetector. + * * @property {number} completionPercent * This is the greatest completion percent that the user has experienced in * playback. Also known as the "high water mark". Is NaN when there is no diff --git a/lib/cast/cast_proxy.js b/lib/cast/cast_proxy.js index e7f1a6afd2..0314255fc4 100644 --- a/lib/cast/cast_proxy.js +++ b/lib/cast/cast_proxy.js @@ -278,8 +278,8 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { (event) => this.videoProxyLocalEvent_(event)); } - for (const key in shaka.Player.EventName) { - const name = shaka.Player.EventName[key]; + for (const key in shaka.util.FakeEvent.EventName) { + const name = shaka.util.FakeEvent.EventName[key]; this.eventManager_.listen(this.localPlayer_, name, (event) => this.playerProxyLocalEvent_(event)); } @@ -537,7 +537,7 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { // Pass any errors through to the app. goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!'); - const eventType = shaka.Player.EventName.Error; + const eventType = shaka.util.FakeEvent.EventName.Error; const data = (new Map()).set('detail', error); const event = new shaka.util.FakeEvent(eventType, data); this.localPlayer_.dispatchEvent(event); diff --git a/lib/cast/cast_receiver.js b/lib/cast/cast_receiver.js index 6d67d3a5a5..008d4d7eec 100644 --- a/lib/cast/cast_receiver.js +++ b/lib/cast/cast_receiver.js @@ -281,8 +281,8 @@ shaka.cast.CastReceiver = class extends shaka.util.FakeEventTarget { this.video_, name, (event) => this.proxyEvent_('video', event)); } - for (const key in shaka.Player.EventName) { - const name = shaka.Player.EventName[key]; + for (const key in shaka.util.FakeEvent.EventName) { + const name = shaka.util.FakeEvent.EventName[key]; this.eventManager_.listen( this.player_, name, (event) => this.proxyEvent_('player', event)); } @@ -409,7 +409,7 @@ shaka.cast.CastReceiver = class extends shaka.util.FakeEventTarget { // Pass any errors through to the app. goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!'); - const eventType = shaka.Player.EventName.Error; + const eventType = shaka.util.FakeEvent.EventName.Error; const data = (new Map()).set('detail', error); const event = new shaka.util.FakeEvent(eventType, data); // Only dispatch the event if the player still exists. diff --git a/lib/media/gap_jumping_controller.js b/lib/media/gap_jumping_controller.js index 636fd245ac..cea9059c58 100644 --- a/lib/media/gap_jumping_controller.js +++ b/lib/media/gap_jumping_controller.js @@ -11,6 +11,7 @@ goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.StallDetector'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.util.EventManager'); +goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IReleasable'); goog.require('shaka.util.Timer'); @@ -32,8 +33,13 @@ shaka.media.GapJumpingController = class { * playable region. The gap jumping controller takes ownership over the * stall detector. * If no stall detection logic is desired, |null| may be provided. + * @param {function(!Event)} onEvent + * Called when an event is raised to be sent to the application. */ - constructor(video, timeline, config, stallDetector) { + constructor(video, timeline, config, stallDetector, onEvent) { + /** @private {?function(!Event)} */ + this.onEvent_ = onEvent; + /** @private {HTMLMediaElement} */ this.video_ = video; @@ -52,6 +58,9 @@ shaka.media.GapJumpingController = class { /** @private {number} */ this.prevReadyState_ = video.readyState; + /** @private {number} */ + this.gapsJumped_ = 0; + /** * The stall detector tries to keep the playhead moving forward. It is * managed by the gap-jumping controller to avoid conflicts. On some @@ -98,6 +107,7 @@ shaka.media.GapJumpingController = class { this.stallDetector_ = null; } + this.onEvent_ = null; this.timeline_ = null; this.video_ = null; } @@ -121,6 +131,15 @@ shaka.media.GapJumpingController = class { } + /** + * Returns the total number of playback gaps jumped. + * @return {number} + */ + getGapsJumped() { + return this.gapsJumped_; + } + + /** * Called on a recurring timer to check for gaps in the media. This is also * called in a 'waiting' event. @@ -209,6 +228,9 @@ shaka.media.GapJumpingController = class { } this.video_.currentTime = jumpTo; + this.gapsJumped_++; + this.onEvent_( + new shaka.util.FakeEvent(shaka.util.FakeEvent.EventName.GapJumped)); } }; diff --git a/lib/media/playhead.js b/lib/media/playhead.js index 462af1907c..1200b51940 100644 --- a/lib/media/playhead.js +++ b/lib/media/playhead.js @@ -41,6 +41,20 @@ shaka.media.Playhead = class { */ setStartTime(startTime) {} + /** + * Get the number of playback stalls detected by the StallDetector. + * + * @return {number} + */ + getStallsDetected() {} + + /** + * Get the number of playback gaps jumped by the GapJumpingController. + * + * @return {number} + */ + getGapsJumped() {} + /** * Get the current playhead position. The position will be restricted to valid * time ranges. @@ -133,6 +147,16 @@ shaka.media.SrcEqualsPlayhead = class { return time || 0; } + /** @override */ + getStallsDetected() { + return 0; + } + + /** @override */ + getGapsJumped() { + return 0; + } + /** @override */ notifyOfBufferingChange() {} }; @@ -160,8 +184,10 @@ shaka.media.MediaSourcePlayhead = class { * @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) { + 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 @@ -192,12 +218,17 @@ shaka.media.MediaSourcePlayhead = class { /** @private {?number} */ this.lastCorrectiveSeek_ = null; + /** @private {shaka.media.StallDetector} */ + this.stallDetector_ = + this.createStallDetector_(mediaElement, config, onEvent); + /** @private {shaka.media.GapJumpingController} */ this.gapController_ = new shaka.media.GapJumpingController( mediaElement, manifest.presentationTimeline, config, - this.createStallDetector_(mediaElement, config)); + this.stallDetector_, + onEvent); /** @private {shaka.media.VideoWrapper} */ this.videoWrapper_ = new shaka.media.VideoWrapper( @@ -261,6 +292,16 @@ shaka.media.MediaSourcePlayhead = class { return time; } + /** @override */ + getStallsDetected() { + return this.stallDetector_.getStallsDetected(); + } + + /** @override */ + getGapsJumped() { + return this.gapController_.getGapsJumped(); + } + /** * Gets the playhead's initial position in seconds. * @@ -475,10 +516,12 @@ shaka.media.MediaSourcePlayhead = class { * * @param {!HTMLMediaElement} mediaElement * @param {shaka.extern.StreamingConfiguration} config + * @param {function(!Event)} onEvent + * Called when an event is raised to be sent to the application. * @return {shaka.media.StallDetector} * @private */ - createStallDetector_(mediaElement, config) { + createStallDetector_(mediaElement, config, onEvent) { if (!config.stallEnabled) { return null; } @@ -492,7 +535,7 @@ shaka.media.MediaSourcePlayhead = class { // playhead forward. const detector = new shaka.media.StallDetector( new shaka.media.StallDetector.MediaElementImplementation(mediaElement), - threshold); + threshold, onEvent); detector.onStall((at, duration) => { shaka.log.debug(`Stall detected at ${at} for ${duration} seconds.`); diff --git a/lib/media/stall_detector.js b/lib/media/stall_detector.js index 08a96e0d69..6fca50825d 100644 --- a/lib/media/stall_detector.js +++ b/lib/media/stall_detector.js @@ -9,6 +9,7 @@ goog.provide('shaka.media.StallDetector.Implementation'); goog.provide('shaka.media.StallDetector.MediaElementImplementation'); goog.require('shaka.media.TimeRangesUtils'); +goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IReleasable'); /** @@ -23,11 +24,14 @@ shaka.media.StallDetector = class { /** * @param {shaka.media.StallDetector.Implementation} implementation * @param {number} stallThresholdSeconds + * @param {function(!Event)} onEvent + * Called when an event is raised to be sent to the application. */ - constructor(implementation, stallThresholdSeconds) { + constructor(implementation, stallThresholdSeconds, onEvent) { + /** @private {?function(!Event)} */ + this.onEvent_ = onEvent; /** @private {shaka.media.StallDetector.Implementation} */ this.implementation_ = implementation; - /** @private {boolean} */ this.wasMakingProgress_ = implementation.shouldBeMakingProgress(); /** @private {number} */ @@ -36,6 +40,8 @@ shaka.media.StallDetector = class { this.lastUpdateSeconds_ = implementation.getWallSeconds(); /** @private {boolean} */ this.didJump_ = false; + /** @private {number} */ + this.stallsDetected_ = 0; /** * The amount of time in seconds that we must have the same value of @@ -53,6 +59,7 @@ shaka.media.StallDetector = class { release() { // Drop external references to make things easier on the GC. this.implementation_ = null; + this.onEvent_ = null; this.onStall_ = () => {}; } @@ -66,6 +73,13 @@ shaka.media.StallDetector = class { this.onStall_ = doThis; } + /** + * Returns the number of playback stalls detected. + */ + getStallsDetected() { + return this.stallsDetected_; + } + /** * Have the detector update itself and fire the "on stall" callback if a stall * was detected. @@ -100,6 +114,9 @@ shaka.media.StallDetector = class { // If the onStall_ method updated the current time, update our stored // value so we don't think that was an update. this.value_ = impl.getPresentationSeconds(); + this.stallsDetected_++; + this.onEvent_(new shaka.util.FakeEvent( + shaka.util.FakeEvent.EventName.StallDetected)); } return triggerCallback; diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 9da8ef2a5a..4e1bee95ea 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1701,7 +1701,7 @@ shaka.media.StreamingEngine = class { }; // Dispatch an event to notify the application about the emsg box. - const eventName = shaka.Player.EventName.Emsg; + const eventName = shaka.util.FakeEvent.EventName.Emsg; const data = (new Map()).set('detail', emsg); const event = new shaka.util.FakeEvent(eventName, data); this.playerInterface_.onEvent(event); diff --git a/lib/player.js b/lib/player.js index 92a8c52a57..cdc7889278 100644 --- a/lib/player.js +++ b/lib/player.js @@ -385,6 +385,26 @@ goog.requireType('shaka.routing.Payload'); */ +/** + * @event shaka.Player.StallDetectedEvent + * @description Fired when a stall in playback is detected by the StallDetector. + * Not all stalls are caused by gaps in the buffered ranges. + * @property {string} type + * 'stalldetected' + * @exportDoc + */ + + +/** + * @event shaka.Player.GapJumpedEvent + * @description Fired when the GapJumpingController jumps over a gap in the + * buffered ranges. + * @property {string} type + * 'gapjumped' + * @exportDoc + */ + + /** * @summary The main player object for Shaka Player. * @@ -652,7 +672,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }, enterNode: (node, has, wants) => { this.dispatchEvent(this.makeEvent_( - /* name= */ shaka.Player.EventName.OnStateChange, + /* name= */ shaka.util.FakeEvent.EventName.OnStateChange, /* data= */ (new Map()).set('state', node.name))); const action = actions.get(node); @@ -683,7 +703,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }, onIdle: (node) => { this.dispatchEvent(this.makeEvent_( - /* name= */ shaka.Player.EventName.OnStateIdle, + /* name= */ shaka.util.FakeEvent.EventName.OnStateIdle, /* data= */ (new Map()).set('state', node.name))); }, }; @@ -707,7 +727,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } /** - * @param {!shaka.Player.EventName} name + * @param {!shaka.util.FakeEvent.EventName} name * @param {Map.=} data * @return {!shaka.util.FakeEvent} * @private @@ -1133,7 +1153,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // We dispatch the loading event when someone calls |load| because we want // to surface the user intent. - this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Loading)); + this.dispatchEvent(this.makeEvent_(shaka.util.FakeEvent.EventName.Loading)); // Right away we know what the asset uri and start-of-load time are. We will // fill-in the rest of the information later. @@ -1197,7 +1217,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { events.onEnd = () => { resolve(); // We dispatch the loaded event when the load promise is resolved - this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Loaded)); + this.dispatchEvent( + this.makeEvent_(shaka.util.FakeEvent.EventName.Loaded)); }; events.onCancel = () => reject(this.createAbortLoadError_()); events.onError = (e) => reject(e); @@ -1402,7 +1423,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { await Promise.all(cleanupTasks); // Dispatch the unloading event. - this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Unloading)); + this.dispatchEvent( + this.makeEvent_(shaka.util.FakeEvent.EventName.Unloading)); // Remove everything that has to do with loading content from our payload // since we are releasing everything that depended on it. @@ -1706,7 +1728,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.regionTimeline_.addEventListener('regionadd', (event) => { /** @type {shaka.extern.TimelineRegionInfo} */ const region = event['region']; - this.onRegionEvent_(shaka.Player.EventName.TimelineRegionAdded, region); + this.onRegionEvent_( + shaka.util.FakeEvent.EventName.TimelineRegionAdded, region); if (this.adManager_) { this.adManager_.onDashTimedMetadata(region); @@ -1761,7 +1784,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // This event is fired after the manifest is parsed, but before any // filtering takes place. - const event = this.makeEvent_(shaka.Player.EventName.ManifestParsed); + const event = + this.makeEvent_(shaka.util.FakeEvent.EventName.ManifestParsed); this.dispatchEvent(event); // We require all manifests to have at least one variant. @@ -1835,7 +1859,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }, onEvent: (e) => { this.dispatchEvent(e); - if (e.type == shaka.Player.EventName.DrmSessionUpdate && firstEvent) { + if (e.type == shaka.util.FakeEvent.EventName.DrmSessionUpdate && + firstEvent) { firstEvent = false; const now = Date.now() / 1000; const delta = now - startTime; @@ -2000,7 +2025,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // The event must be fired after we filter by restrictions but before the // active stream is picked to allow those listening for the "streaming" // event to make changes before streaming starts. - this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Streaming)); + this.dispatchEvent( + this.makeEvent_(shaka.util.FakeEvent.EventName.Streaming)); // Pick the initial streams to play. // however, we would skip switch to initial variant @@ -2128,7 +2154,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }, onEvent: (e) => { this.dispatchEvent(e); - if (e.type == shaka.Player.EventName.DrmSessionUpdate && firstEvent) { + if (e.type == shaka.util.FakeEvent.EventName.DrmSessionUpdate && + firstEvent) { firstEvent = false; const now = Date.now() / 1000; const delta = now - startTime; @@ -2354,7 +2381,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // The event doesn't mean as much for src= playback, since we don't control // streaming. But we should fire it in this path anyway since some // applications may be expecting it as a life-cycle event. - this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Streaming)); + this.dispatchEvent( + this.makeEvent_(shaka.util.FakeEvent.EventName.Streaming)); // The "load" Promise is resolved when we have loaded the metadata. If we // wait for the full data, that won't happen on Safari until the play button @@ -2566,7 +2594,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { dispatchMetadataEvent_(startTime, endTime, metadataType, payload) { goog.asserts.assert(!endTime || startTime <= endTime, 'Metadata start time should be less or equal to the end time!'); - const eventName = shaka.Player.EventName.Metadata; + const eventName = shaka.util.FakeEvent.EventName.Metadata; const data = new Map() .set('startTime', startTime) .set('endTime', endTime) @@ -2663,7 +2691,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** @type {shaka.net.NetworkingEngine.OnHeadersReceived} */ const onHeadersReceived_ = (headers, request, requestType) => { // Release a 'downloadheadersreceived' event. - const name = shaka.Player.EventName.DownloadHeadersReceived; + const name = shaka.util.FakeEvent.EventName.DownloadHeadersReceived; const data = new Map() .set('headers', headers) .set('request', request) @@ -2673,7 +2701,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** @type {shaka.net.NetworkingEngine.OnDownloadFailed} */ const onDownloadFailed_ = (request, error, httpResponseCode, aborted) => { // Release a 'downloadfailed' event. - const name = shaka.Player.EventName.DownloadFailed; + const name = shaka.util.FakeEvent.EventName.DownloadFailed; const data = new Map() .set('request', request) .set('error', error) @@ -2701,7 +2729,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.manifest_, this.config_.streaming, startTime, - () => this.onSeek_()); + () => this.onSeek_(), + (event) => this.dispatchEvent(event)); } /** @@ -2723,13 +2752,15 @@ shaka.Player = class extends shaka.util.FakeEventTarget { regionObserver.addEventListener('enter', (event) => { /** @type {shaka.extern.TimelineRegionInfo} */ const region = event['region']; - this.onRegionEvent_(shaka.Player.EventName.TimelineRegionEnter, region); + this.onRegionEvent_( + shaka.util.FakeEvent.EventName.TimelineRegionEnter, region); }); regionObserver.addEventListener('exit', (event) => { /** @type {shaka.extern.TimelineRegionInfo} */ const region = event['region']; - this.onRegionEvent_(shaka.Player.EventName.TimelineRegionExit, region); + this.onRegionEvent_( + shaka.util.FakeEvent.EventName.TimelineRegionExit, region); }); regionObserver.addEventListener('skip', (event) => { @@ -2740,8 +2771,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // If we are seeking, we don't want to surface the enter/exit events since // they didn't play through them. if (!seeking) { - this.onRegionEvent_(shaka.Player.EventName.TimelineRegionEnter, region); - this.onRegionEvent_(shaka.Player.EventName.TimelineRegionExit, region); + this.onRegionEvent_( + shaka.util.FakeEvent.EventName.TimelineRegionEnter, region); + this.onRegionEvent_( + shaka.util.FakeEvent.EventName.TimelineRegionExit, region); } }); @@ -4368,6 +4401,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.stats_.setCompletionPercent(Math.round(100 * completionRatio)); } + if (this.playhead_) { + this.stats_.setGapsJumped(this.playhead_.getGapsJumped()); + this.stats_.setStallsDetected(this.playhead_.getStallsDetected()); + } + if (element.getVideoPlaybackQuality) { const info = element.getVideoPlaybackQuality(); @@ -5131,7 +5169,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // Surface the buffering event so that the app knows if/when we are // buffering. - const eventName = shaka.Player.EventName.Buffering; + const eventName = shaka.util.FakeEvent.EventName.Buffering; const data = (new Map()).set('buffering', isBuffering); this.dispatchEvent(this.makeEvent_(eventName, data)); } @@ -5166,7 +5204,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.playRateController_.set(newRate); } - const event = this.makeEvent_(shaka.Player.EventName.RateChange); + const event = this.makeEvent_(shaka.util.FakeEvent.EventName.RateChange); this.dispatchEvent(event); } @@ -5513,7 +5551,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const data = new Map() .set('oldTrack', from) .set('newTrack', to); - const event = this.makeEvent_(shaka.Player.EventName.Adaptation, data); + const event = + this.makeEvent_(shaka.util.FakeEvent.EventName.Adaptation, data); this.delayDispatchEvent_(event); } @@ -5524,7 +5563,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { onTracksChanged_() { // Delay the 'trackschanged' event so StreamingEngine has time to absorb the // changes before the user tries to query it. - const event = this.makeEvent_(shaka.Player.EventName.TracksChanged); + const event = this.makeEvent_(shaka.util.FakeEvent.EventName.TracksChanged); this.delayDispatchEvent_(event); } @@ -5540,7 +5579,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const data = new Map() .set('oldTrack', from) .set('newTrack', to); - const event = this.makeEvent_(shaka.Player.EventName.VariantChanged, data); + const event = + this.makeEvent_(shaka.util.FakeEvent.EventName.VariantChanged, data); this.delayDispatchEvent_(event); } @@ -5551,13 +5591,14 @@ shaka.Player = class extends shaka.util.FakeEventTarget { onTextChanged_() { // Delay the 'textchanged' event so StreamingEngine time to absorb the // changes before the user tries to query it. - const event = this.makeEvent_(shaka.Player.EventName.TextChanged); + const event = this.makeEvent_(shaka.util.FakeEvent.EventName.TextChanged); this.delayDispatchEvent_(event); } /** @private */ onTextTrackVisibility_() { - const event = this.makeEvent_(shaka.Player.EventName.TextTrackVisibility); + const event = + this.makeEvent_(shaka.util.FakeEvent.EventName.TextTrackVisibility); this.delayDispatchEvent_(event); } @@ -5565,7 +5606,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { onAbrStatusChanged_() { const data = (new Map()).set('newStatus', this.config_.abr.enabled); this.delayDispatchEvent_(this.makeEvent_( - shaka.Player.EventName.AbrStatusChanged, data)); + shaka.util.FakeEvent.EventName.AbrStatusChanged, data)); } /** @@ -5653,7 +5694,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return; } - const eventName = shaka.Player.EventName.Error; + const eventName = shaka.util.FakeEvent.EventName.Error; const event = this.makeEvent_(eventName, (new Map()).set('detail', error)); this.dispatchEvent(event); if (event.defaultPrevented) { @@ -5667,7 +5708,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * copy here because this is the transition point between the player and the * app. * - * @param {!shaka.Player.EventName} eventName + * @param {!shaka.util.FakeEvent.EventName} eventName * @param {shaka.extern.TimelineRegionInfo} region * * @private @@ -5716,7 +5757,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { .set('position', position); this.dispatchEvent(this.makeEvent_( - shaka.Player.EventName.MediaQualityChanged, data)); + shaka.util.FakeEvent.EventName.MediaQualityChanged, data)); } /** @@ -5862,7 +5903,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.parser_.onExpirationUpdated(keyId, expiration); } - const event = this.makeEvent_(shaka.Player.EventName.ExpirationUpdated); + const event = + this.makeEvent_(shaka.util.FakeEvent.EventName.ExpirationUpdated); this.dispatchEvent(event); } @@ -6475,42 +6517,6 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } }; -/** - * An internal enum that contains the string values of all of the player events. - * This exists primarily to act as an implicit list of events, for tests. - * - * @enum {string} - */ -shaka.Player.EventName = { - AbrStatusChanged: 'abrstatuschanged', - Adaptation: 'adaptation', - Buffering: 'buffering', - DownloadFailed: 'downloadfailed', - DownloadHeadersReceived: 'downloadheadersreceived', - DrmSessionUpdate: 'drmsessionupdate', - Emsg: 'emsg', - Error: 'error', - ExpirationUpdated: 'expirationupdated', - Loaded: 'loaded', - Loading: 'loading', - ManifestParsed: 'manifestparsed', - MediaQualityChanged: 'mediaqualitychanged', - Metadata: 'metadata', - OnStateChange: 'onstatechange', - OnStateIdle: 'onstateidle', - RateChange: 'ratechange', - SessionDataEvent: 'sessiondata', - Streaming: 'streaming', - TextChanged: 'textchanged', - TextTrackVisibility: 'texttrackvisibility', - TimelineRegionAdded: 'timelineregionadded', - TimelineRegionEnter: 'timelineregionenter', - TimelineRegionExit: 'timelineregionexit', - TracksChanged: 'trackschanged', - Unloading: 'unloading', - VariantChanged: 'variantchanged', -}; - /** * In order to know what method of loading the player used for some content, we diff --git a/lib/util/fake_event.js b/lib/util/fake_event.js index 52ead9bd17..a4f9f20b22 100644 --- a/lib/util/fake_event.js +++ b/lib/util/fake_event.js @@ -144,3 +144,42 @@ shaka.util.FakeEvent = class { */ stopPropagation() {} }; + + +/** + * An internal enum that contains the string values of all of the player events. + * This exists primarily to act as an implicit list of events, for tests. + * + * @enum {string} + */ +shaka.util.FakeEvent.EventName = { + AbrStatusChanged: 'abrstatuschanged', + Adaptation: 'adaptation', + Buffering: 'buffering', + DownloadFailed: 'downloadfailed', + DownloadHeadersReceived: 'downloadheadersreceived', + DrmSessionUpdate: 'drmsessionupdate', + Emsg: 'emsg', + Error: 'error', + ExpirationUpdated: 'expirationupdated', + GapJumped: 'gapjumped', + Loaded: 'loaded', + Loading: 'loading', + ManifestParsed: 'manifestparsed', + MediaQualityChanged: 'mediaqualitychanged', + Metadata: 'metadata', + OnStateChange: 'onstatechange', + OnStateIdle: 'onstateidle', + RateChange: 'ratechange', + SessionDataEvent: 'sessiondata', + StallDetected: 'stalldetected', + Streaming: 'streaming', + TextChanged: 'textchanged', + TextTrackVisibility: 'texttrackvisibility', + TimelineRegionAdded: 'timelineregionadded', + TimelineRegionEnter: 'timelineregionenter', + TimelineRegionExit: 'timelineregionexit', + TracksChanged: 'trackschanged', + Unloading: 'unloading', + VariantChanged: 'variantchanged', +}; diff --git a/lib/util/stats.js b/lib/util/stats.js index 34f4e7eb5e..bea339f016 100644 --- a/lib/util/stats.js +++ b/lib/util/stats.js @@ -31,6 +31,11 @@ shaka.util.Stats = class { /** @private {number} */ this.totalCorruptedFrames_ = NaN; + /** @private {number} */ + this.totalStallsDetected_ = NaN; + /** @private {number} */ + this.totalGapsJumped_ = NaN; + /** @private {number} */ this.completionPercent_ = NaN; @@ -76,7 +81,6 @@ shaka.util.Stats = class { this.totalDecodedFrames_ = decoded; } - /** * Update corrupted frames. This will replace the previous values. * @@ -86,6 +90,25 @@ shaka.util.Stats = class { this.totalCorruptedFrames_ = corrupted; } + /** + * Update number of stalls detected. This will replace the previous value. + * + * @param {number} stallsDetected + */ + setStallsDetected(stallsDetected) { + this.totalStallsDetected_ = stallsDetected; + } + + /** + * Update number of playback gaps jumped over. This will replace the previous + * value. + * + * @param {number} gapsJumped + */ + setGapsJumped(gapsJumped) { + this.totalGapsJumped_ = gapsJumped; + } + /** * Set the width and height of the video we are currently playing. * @@ -208,6 +231,8 @@ shaka.util.Stats = class { decodedFrames: this.totalDecodedFrames_, droppedFrames: this.totalDroppedFrames_, corruptedFrames: this.totalCorruptedFrames_, + stallsDetected: this.totalStallsDetected_, + gapsJumped: this.totalGapsJumped_, estimatedBandwidth: this.bandwidthEstimate_, completionPercent: this.completionPercent_, loadLatency: this.loadLatencySeconds_, @@ -238,6 +263,8 @@ shaka.util.Stats = class { decodedFrames: NaN, droppedFrames: NaN, corruptedFrames: NaN, + stallsDetected: NaN, + gapsJumped: NaN, estimatedBandwidth: NaN, completionPercent: NaN, loadLatency: NaN, diff --git a/test/media/playhead_unit.js b/test/media/playhead_unit.js index 98dffc5950..67113d1984 100644 --- a/test/media/playhead_unit.js +++ b/test/media/playhead_unit.js @@ -21,12 +21,15 @@ let TimeRange; * start: number, * waitingAt: number, * expectedEndTime: number, + * expectEvent: boolean, * }} * * @description * Parameters for a test where we start playing inside a buffered range and play * until the end of the buffer. Then, if we expect it, Playhead should jump - * to the expected time. + * to the expected time. We should get a 'stalldetected' event when the Playhead + * detects a stall through the StallDetector, and a 'gapjumped' event when the + * Playhead jumps over a gap in the buffered range(s). * * @property {!Array.} buffered * The buffered ranges for the test. @@ -36,6 +39,9 @@ let TimeRange; * The time to pause at and fire a 'waiting' event. * @property {number} expectedEndTime * The expected time at the end of the test. + * @property {boolean} expectEvent + * If true, expect either the 'stalldetected' or 'gapjumped' event to be + * fired. */ let PlayingTestInfo; @@ -47,6 +53,7 @@ let PlayingTestInfo; * start: number, * seekTo: number, * expectedEndTime: number, + * expectEvent: boolean, * }} * * @description @@ -65,6 +72,9 @@ let PlayingTestInfo; * The time to seek to. * @property {number} expectedEndTime * The expected time at the end of the test. + * @property {boolean} expectEvent + * If true, expect either the 'stalldetected' or 'gapjumped' event to be + * fired. */ let SeekTestInfo; @@ -87,6 +97,10 @@ describe('Playhead', () => { /** @type {!jasmine.Spy} */ let onSeek; + // Callback to us from Playhead when an event should be sent to the app. + /** @type {!jasmine.Spy} */ + let onEvent; + beforeAll(() => { jasmine.clock().install(); }); @@ -100,6 +114,7 @@ describe('Playhead', () => { timeline = new shaka.test.FakePresentationTimeline(); onSeek = jasmine.createSpy('onSeek'); + onEvent = jasmine.createSpy('onEvent'); timeline.isLive.and.returnValue(false); timeline.getSeekRangeStart.and.returnValue(5); @@ -143,7 +158,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(video.currentTime).toBe(5); expect(playhead.getTime()).toBe(5); @@ -163,7 +179,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(video.addEventListener).toHaveBeenCalledWith( 'loadedmetadata', jasmine.any(Function), jasmine.anything()); @@ -203,7 +220,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); video.on['seeking'](); expect(playhead.getTime()).toBe(5); @@ -221,7 +239,12 @@ describe('Playhead', () => { timeline.getSeekRangeEnd.and.returnValue(60); playhead = new shaka.media.MediaSourcePlayhead( - video, manifest, config, /* startTime= */ 0, Util.spyFunc(onSeek)); + video, + manifest, + config, + /* startTime= */ 0, + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(playhead.getTime()).toBe(0); }); @@ -234,7 +257,12 @@ describe('Playhead', () => { timeline.getDuration.and.returnValue(60); playhead = new shaka.media.MediaSourcePlayhead( - video, manifest, config, /* startTime= */ 60, Util.spyFunc(onSeek)); + video, + manifest, + config, + /* startTime= */ 60, + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(playhead.getTime()).toBe(59); // duration - durationBackoff expect(video.currentTime).toBe(59); // duration - durationBackoff @@ -248,7 +276,12 @@ describe('Playhead', () => { timeline.getSeekRangeEnd.and.returnValue(60); playhead = new shaka.media.MediaSourcePlayhead( - video, manifest, config, /* startTime= */ -15, Util.spyFunc(onSeek)); + video, + manifest, + config, + /* startTime= */ -15, + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(playhead.getTime()).toBe(45); }); @@ -262,7 +295,12 @@ describe('Playhead', () => { // If the live stream's playback offset time is not available, start // playing from the seek range start time. playhead = new shaka.media.MediaSourcePlayhead( - video, manifest, config, /* startTime= */ -40, Util.spyFunc(onSeek)); + video, + manifest, + config, + /* startTime= */ -40, + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(playhead.getTime()).toBe(30); }); @@ -273,7 +311,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(video.addEventListener).toHaveBeenCalledWith( 'loadedmetadata', jasmine.any(Function), jasmine.anything()); @@ -305,7 +344,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ null, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(video.addEventListener).toHaveBeenCalledWith( 'loadedmetadata', jasmine.any(Function), jasmine.anything()); @@ -337,7 +377,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); // This has to periodically increment the mock date to allow the onSeeking_ // handler to seek, if appropriate. @@ -496,7 +537,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); setMockDate(0); video.on['seeking'](); @@ -544,7 +586,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); // First, seek to start time. video.currentTime = 0; @@ -604,7 +647,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); expect(currentTime).toBe(1000); seekCount = 0; @@ -649,7 +693,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); video.on['seeking'](); expect(video.currentTime).toBe(5); @@ -680,7 +725,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); video.on['seeking'](); expect(video.currentTime).toBe(5); @@ -714,7 +760,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 30, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); video.currentTime = 0; video.seeking = true; @@ -759,7 +806,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 30, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); /** * Prevent retries on the initial start time seek. This will ensure that @@ -798,6 +846,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}], start: 3, waitingAt: 10, + expectEvent: false, expectedEndTime: 10, }); @@ -805,6 +854,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 20, end: 30}], start: 24, waitingAt: 30, + expectEvent: false, expectedEndTime: 30, }); @@ -812,6 +862,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 11, end: 20}], start: 5, waitingAt: 10, + expectEvent: true, expectedEndTime: 11, }); @@ -820,6 +871,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 11, end: 20}, {start: 21, end: 30}], start: 5, waitingAt: 10, + expectEvent: true, expectedEndTime: 11, }); @@ -828,6 +880,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 11, end: 20}, {start: 21, end: 30}], start: 15, waitingAt: 20, + expectEvent: true, expectedEndTime: 21, }); }); // with small gaps @@ -837,6 +890,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 30, end: 40}], start: 5, waitingAt: 10, + expectEvent: true, expectedEndTime: 30, }); @@ -845,6 +899,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 30, end: 40}, {start: 50, end: 60}], start: 5, waitingAt: 10, + expectEvent: true, expectedEndTime: 30, }); @@ -853,6 +908,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 20, end: 30}, {start: 50, end: 60}], start: 24, waitingAt: 30, + expectEvent: true, expectedEndTime: 50, }); }); // with large gaps @@ -867,12 +923,15 @@ describe('Playhead', () => { video.currentTime = data.start; video.readyState = HTMLMediaElement.HAVE_ENOUGH_DATA; + onEvent.and.callFake((event) => {}); + playhead = new shaka.media.MediaSourcePlayhead( video, manifest, config, /* startTime= */ data.start, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); jasmine.clock().tick(500); for (let time = data.start; time < data.waitingAt; time++) { @@ -893,11 +952,14 @@ describe('Playhead', () => { expect(video.currentTime).toBe(time + 0.4); } + expect(onEvent).not.toHaveBeenCalled(); + video.currentTime = data.waitingAt; video.readyState = HTMLMediaElement.HAVE_CURRENT_DATA; video.on['waiting'](); jasmine.clock().tick(500); + expect(onEvent).toHaveBeenCalledTimes(data.expectEvent ? 1 : 0); expect(video.currentTime).toBe(data.expectedEndTime); }); } @@ -909,6 +971,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}], start: 4, seekTo: 14, + expectEvent: false, expectedEndTime: 14, }); @@ -916,6 +979,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 11, end: 20}], start: 3, seekTo: 10.4, + expectEvent: true, expectedEndTime: 11, }); @@ -924,6 +988,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 11, end: 20}, {start: 21, end: 30}], start: 3, seekTo: 10.4, + expectEvent: true, expectedEndTime: 11, }); @@ -932,6 +997,7 @@ describe('Playhead', () => { [{start: 0, end: 10}, {start: 11, end: 20}, {start: 21, end: 30}], start: 3, seekTo: 20.5, + expectEvent: true, expectedEndTime: 21, }); @@ -939,6 +1005,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 30, end: 40}], start: 3, seekTo: 29.2, + expectEvent: true, expectedEndTime: 30, }); }); // with small gaps @@ -948,6 +1015,7 @@ describe('Playhead', () => { buffered: [{start: 0, end: 10}, {start: 30, end: 40}], start: 5, seekTo: 12, + expectEvent: true, expectedEndTime: 30, }); }); // with large gaps @@ -961,6 +1029,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}, {start: 31, end: 40}], start: 3, seekTo: 22, + expectEvent: false, expectedEndTime: 22, }); @@ -971,6 +1040,7 @@ describe('Playhead', () => { newBuffered: [{start: 0.2, end: 10}], start: 4, seekTo: 0, + expectEvent: true, expectedEndTime: 0.2, }); @@ -980,6 +1050,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}, {start: 31, end: 40}], start: 3, seekTo: 30.2, + expectEvent: true, expectedEndTime: 31, }); @@ -989,6 +1060,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}, {start: 31, end: 40}], start: 3, seekTo: 30, + expectEvent: true, expectedEndTime: 31, }); @@ -998,6 +1070,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}], start: 3, seekTo: 34, + expectEvent: false, expectedEndTime: 34, }); @@ -1007,6 +1080,7 @@ describe('Playhead', () => { newBuffered: [{start: 0, end: 10}], start: 24, seekTo: 4, + expectEvent: false, expectedEndTime: 4, }); @@ -1017,6 +1091,7 @@ describe('Playhead', () => { // should still be waiting. start: 24, seekTo: 4, + expectEvent: false, expectedEndTime: 4, }); @@ -1026,6 +1101,7 @@ describe('Playhead', () => { newBuffered: [{start: 2, end: 10}], start: 24, seekTo: 1.6, + expectEvent: true, expectedEndTime: 2, }); }); // with small gaps @@ -1036,6 +1112,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}], start: 25, seekTo: 0, + expectEvent: true, expectedEndTime: 20, }); @@ -1045,6 +1122,7 @@ describe('Playhead', () => { newBuffered: [{start: 20, end: 30}, {start: 40, end: 50}], start: 3, seekTo: 32, + expectEvent: true, expectedEndTime: 40, }); }); // with large gaps @@ -1061,9 +1139,11 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 12, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); jasmine.clock().tick(500); + expect(onEvent).not.toHaveBeenCalled(); // Append a segment before seeking. playhead.notifyOfBufferingChange(); @@ -1107,7 +1187,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 0, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); playhead.notifyOfBufferingChange(); jasmine.clock().tick(500); @@ -1128,7 +1209,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 5, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); playhead.notifyOfBufferingChange(); jasmine.clock().tick(500); @@ -1151,7 +1233,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 0, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); playhead.notifyOfBufferingChange(); jasmine.clock().tick(500); @@ -1174,7 +1257,8 @@ describe('Playhead', () => { manifest, config, /* startTime= */ 0, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); playhead.notifyOfBufferingChange(); jasmine.clock().tick(500); @@ -1193,14 +1277,18 @@ describe('Playhead', () => { video.currentTime = data.start; video.readyState = HTMLMediaElement.HAVE_ENOUGH_DATA; + onEvent.and.callFake((event) => {}); + playhead = new shaka.media.MediaSourcePlayhead( video, manifest, config, /* startTime= */ data.start, - Util.spyFunc(onSeek)); + Util.spyFunc(onSeek), + Util.spyFunc(onEvent)); jasmine.clock().tick(500); + expect(onEvent).not.toHaveBeenCalled(); // Seek to the given position and update ready state. video.currentTime = data.seekTo; @@ -1232,6 +1320,7 @@ describe('Playhead', () => { jasmine.clock().tick(250); } + expect(onEvent).toHaveBeenCalledTimes(data.expectEvent ? 1 : 0); expect(video.currentTime).toBe(data.expectedEndTime); }); } diff --git a/test/media/stall_detector_unit.js b/test/media/stall_detector_unit.js index bbe1b55739..aaab2ae6c9 100644 --- a/test/media/stall_detector_unit.js +++ b/test/media/stall_detector_unit.js @@ -5,6 +5,8 @@ */ describe('StallDetector', () => { + const Util = shaka.test.Util; + /** * @implements {shaka.media.StallDetector.Implementation} * @final @@ -38,16 +40,21 @@ describe('StallDetector', () => { /** @type {!jasmine.Spy} */ let onStall; + /** @type {!jasmine.Spy} */ + let onEvent; + beforeEach(() => { onStall = jasmine.createSpy('onStall'); + onEvent = jasmine.createSpy('onEvent'); implementation = new TestImplementation(); detector = new shaka.media.StallDetector( implementation, - /* stallThresholdSeconds= */ 1); + /* stallThresholdSeconds= */ 1, + Util.spyFunc(onEvent)); - detector.onStall(shaka.test.Util.spyFunc(onStall)); + detector.onStall(Util.spyFunc(onStall)); }); it('does not call onStall when values changes', () => { @@ -63,6 +70,7 @@ describe('StallDetector', () => { detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); }); @@ -74,6 +82,7 @@ describe('StallDetector', () => { implementation.wallSeconds = time; detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); } @@ -81,6 +90,7 @@ describe('StallDetector', () => { implementation.wallSeconds = 1; detector.poll(); + expect(onEvent).toHaveBeenCalled(); expect(onStall).toHaveBeenCalledOnceMoreWith([ /* stalledWith= */ 5, /* stallDurationSeconds= */ 1, @@ -96,6 +106,7 @@ describe('StallDetector', () => { implementation.wallSeconds = 10; detector.poll(); + expect(onEvent).toHaveBeenCalled(); expect(onStall).toHaveBeenCalledOnceMoreWith([ /* stalledWith= */ 5, /* stallDurationms= */ 10, @@ -117,6 +128,7 @@ describe('StallDetector', () => { detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); }); @@ -135,6 +147,7 @@ describe('StallDetector', () => { implementation.wallSeconds = 10; detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); }); @@ -143,16 +156,20 @@ describe('StallDetector', () => { implementation.presentationSeconds = 0; implementation.wallSeconds = 0; detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); implementation.wallSeconds = 10; detector.poll(); + expect(onEvent).toHaveBeenCalled(); expect(onStall).toHaveBeenCalled(); + onEvent.calls.reset(); onStall.calls.reset(); // This is the same stall, should not be called again. implementation.wallSeconds = 20; detector.poll(); + expect(onEvent).not.toHaveBeenCalled(); expect(onStall).not.toHaveBeenCalled(); // Now that we changed time, we should get another call. @@ -161,6 +178,7 @@ describe('StallDetector', () => { detector.poll(); implementation.wallSeconds = 40; detector.poll(); + expect(onEvent).toHaveBeenCalled(); expect(onStall).toHaveBeenCalled(); }); }); diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 150403ecaf..36af6b43d6 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -214,7 +214,8 @@ describe('StreamingEngine', () => { manifest, config, /* startTime= */ null, - onSeek); + onSeek, + shaka.test.Util.spyFunc(onEvent)); } function setupManifest( diff --git a/test/player_integration.js b/test/player_integration.js index 01eeb8b06e..5411ed14ea 100644 --- a/test/player_integration.js +++ b/test/player_integration.js @@ -126,6 +126,9 @@ describe('Player', () => { corruptedFrames: jasmine.any(Number), estimatedBandwidth: jasmine.any(Number), + gapsJumped: jasmine.any(Number), + stallsDetected: jasmine.any(Number), + completionPercent: jasmine.any(Number), loadLatency: jasmine.any(Number), manifestTimeSeconds: jasmine.any(Number), diff --git a/test/test/util/simple_fakes.js b/test/test/util/simple_fakes.js index f22ed4ad3a..12c9c94954 100644 --- a/test/test/util/simple_fakes.js +++ b/test/test/util/simple_fakes.js @@ -333,6 +333,12 @@ shaka.test.FakePlayhead = class { /** @private {number} */ this.startTime_ = 0; + /** @private {number} */ + this.gapsJumped_ = 0; + + /** @private {number} */ + this.stallsDetected_ = 0; + /** @type {!jasmine.Spy} */ this.setStartTime = jasmine.createSpy('setStartTime') .and.callFake((value) => { @@ -343,6 +349,14 @@ shaka.test.FakePlayhead = class { this.getTime = jasmine.createSpy('getTime') .and.callFake(() => this.startTime_); + /** @type {!jasmine.Spy} */ + this.getGapsJumped = jasmine.createSpy('getGapsJumped') + .and.callFake(() => this.gapsJumped_); + + /** @type {!jasmine.Spy} */ + this.getStallsDetected = jasmine.createSpy('getTime') + .and.callFake(() => this.stallsDetected_); + /** @type {!jasmine.Spy} */ this.setBuffering = jasmine.createSpy('setBuffering'); diff --git a/test/ui/ui_unit.js b/test/ui/ui_unit.js index 887536aa87..0f475a3622 100644 --- a/test/ui/ui_unit.js +++ b/test/ui/ui_unit.js @@ -809,12 +809,25 @@ describe('UI', () => { } }); it('is updated periodically', async () => { - function getStatsFromContainer() { + // There is no guaranteed ordering, so fetch by the stat name. + function getStatsElementByName(name) { const nodes = statisticsContainer.childNodes; - width = nodes[0].childNodes[1].textContent.replace(' (px)', ''); - height = nodes[1].childNodes[1].textContent.replace(' (px)', ''); - bufferingTime = - nodes[13].childNodes[1].textContent.replace(' (s)', ''); + for (const node of nodes) { + if (node.hasChildNodes() && + node.childNodes[0].textContent.includes(name)) { + return node; + } + } + return null; + } + + function getStatsFromContainer() { + width = getStatsElementByName( + 'width').childNodes[1].textContent.replace(' (px)', ''); + height = getStatsElementByName( + 'height').childNodes[1].textContent.replace(' (px)', ''); + bufferingTime = getStatsElementByName( + 'bufferingTime').childNodes[1].textContent.replace(' (s)', ''); } /** @type {!string} */ diff --git a/ui/statistics_button.js b/ui/statistics_button.js index dd98410e1e..2b0a23089b 100644 --- a/ui/statistics_button.js +++ b/ui/statistics_button.js @@ -109,6 +109,14 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { this.currentStats_[name], false) + ' (m)'; }; + const parseGaps = (name) => { + return this.currentStats_[name] + ' (gaps)'; + }; + + const parseStalls = (name) => { + return this.currentStats_[name] + ' (stalls)'; + }; + /** @private {!Object.} */ this.parseFrom_ = { 'width': parsePx, @@ -128,6 +136,8 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { 'corruptedFrames': parseFrames, 'decodedFrames': parseFrames, 'droppedFrames': parseFrames, + 'stallsDetected': parseStalls, + 'gapsJumped': parseGaps, }; /** @private {shaka.util.Timer} */