56cb8748 by Jon-Carlos Rivera Committed by GitHub

Intelligent seeking over un-closeable buffer gaps - Part Deux (#735)

* Working implementation of adaptive seeking
* Use this.tech_
* Added the ability for the gap skipper to clean up after itself on dispose
1 parent dca5809b
1 /**
2 * @file gap-skipper.js
3 */
4 import Ranges from './ranges';
5 import videojs from 'video.js';
6
7 // Set of events that reset the gap-skipper logic and clear the timeout
8 const timerCancelEvents = [
9 'seeking',
10 'seeked',
11 'pause',
12 'playing',
13 'error'
14 ];
15
16 /**
17 * The gap skipper object handles all scenarios
18 * where the player runs into the end of a buffered
19 * region and there is a buffered region ahead.
20 *
21 * It then handles the skipping behavior by setting a
22 * timer to the size (in time) of the gap. This gives
23 * the hls segment fetcher time to close the gap and
24 * resume playing before the timer is triggered and
25 * the gap skipper simply seeks over the gap as a
26 * last resort to resume playback.
27 *
28 * @class GapSkipper
29 */
30 export default class GapSkipper {
31 /**
32 * Represents a GapSKipper object.
33 * @constructor
34 * @param {object} options an object that includes the tech and settings
35 */
36 constructor(options) {
37 this.tech_ = options.tech;
38 this.consecutiveUpdates = 0;
39 this.lastRecordedTime = null;
40 this.timer_ = null;
41
42 if (options.debug) {
43 this.logger_ = videojs.log.bind(videojs, 'gap-skipper ->');
44 }
45 this.logger_('initialize');
46
47 let waitingHandler = ()=> this.waiting_();
48 let timeupdateHandler = ()=> this.timeupdate_();
49 let cancelTimerHandler = ()=> this.cancelTimer_();
50
51 this.tech_.on('waiting', waitingHandler);
52 this.tech_.on('timeupdate', timeupdateHandler);
53 this.tech_.on(timerCancelEvents, cancelTimerHandler);
54
55 // Define the dispose function to clean up our events
56 this.dispose = () => {
57 this.logger_('dispose');
58 this.tech_.off('waiting', waitingHandler);
59 this.tech_.off('timeupdate', timeupdateHandler);
60 this.tech_.off(timerCancelEvents, cancelTimerHandler);
61 this.cancelTimer_();
62 };
63 }
64
65 /**
66 * Handler for `waiting` events from the player
67 *
68 * @private
69 */
70 waiting_() {
71 if (!this.tech_.seeking()) {
72 this.setTimer_();
73 }
74 }
75
76 /**
77 * The purpose of this function is to emulate the "waiting" event on
78 * browsers that do not emit it when they are waiting for more
79 * data to continue playback
80 *
81 * @private
82 */
83 timeupdate_() {
84 if (this.tech_.paused() || this.tech_.seeking()) {
85 return;
86 }
87
88 let currentTime = this.tech_.currentTime();
89
90 if (this.consecutiveUpdates === 5 &&
91 currentTime === this.lastRecordedTime) {
92 this.consecutiveUpdates++;
93 this.waiting_();
94 } else if (currentTime === this.lastRecordedTime) {
95 this.consecutiveUpdates++;
96 } else {
97 this.consecutiveUpdates = 0;
98 this.lastRecordedTime = currentTime;
99 }
100 }
101
102 /**
103 * Cancels any pending timers and resets the 'timeupdate' mechanism
104 * designed to detect that we are stalled
105 *
106 * @private
107 */
108 cancelTimer_() {
109 this.consecutiveUpdates = 0;
110
111 if (this.timer_) {
112 this.logger_('cancelTimer_');
113 clearTimeout(this.timer_);
114 }
115
116 this.timer_ = null;
117 }
118
119 /**
120 * Timer callback. If playback still has not proceeded, then we seek
121 * to the start of the next buffered region.
122 *
123 * @private
124 */
125 skipTheGap_(scheduledCurrentTime) {
126 let buffered = this.tech_.buffered();
127 let currentTime = this.tech_.currentTime();
128 let nextRange = Ranges.findNextRange(buffered, currentTime);
129
130 this.consecutiveUpdates = 0;
131 this.timer_ = null;
132
133 if (nextRange.length === 0 ||
134 currentTime !== scheduledCurrentTime) {
135 return;
136 }
137
138 this.logger_('skipTheGap_:',
139 'currentTime:', currentTime,
140 'scheduled currentTime:', scheduledCurrentTime,
141 'nextRange start:', nextRange.start(0));
142
143 // only seek if we still have not played
144 this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR);
145 }
146
147 /**
148 * Set a timer to skip the unbuffered region.
149 *
150 * @private
151 */
152 setTimer_() {
153 let buffered = this.tech_.buffered();
154 let currentTime = this.tech_.currentTime();
155 let nextRange = Ranges.findNextRange(buffered, currentTime);
156
157 if (nextRange.length === 0 ||
158 this.timer_ !== null) {
159 return;
160 }
161
162 let difference = nextRange.start(0) - currentTime;
163
164 this.logger_('setTimer_:',
165 'stopped at:', currentTime,
166 'setting timer for:', difference,
167 'seeking to:', nextRange.start(0));
168
169 this.timer_ = setTimeout(this.skipTheGap_.bind(this),
170 difference * 1000,
171 currentTime);
172 }
173
174 /**
175 * A debugging logger noop that is set to console.log only if debugging
176 * is enabled globally
177 *
178 * @private
179 */
180 logger_() {}
181 }
...@@ -16,6 +16,7 @@ import videojs from 'video.js'; ...@@ -16,6 +16,7 @@ import videojs from 'video.js';
16 import MasterPlaylistController from './master-playlist-controller'; 16 import MasterPlaylistController from './master-playlist-controller';
17 import Config from './config'; 17 import Config from './config';
18 import renditionSelectionMixin from './rendition-mixin'; 18 import renditionSelectionMixin from './rendition-mixin';
19 import GapSkipper from './gap-skipper';
19 20
20 /** 21 /**
21 * determine if an object a is differnt from 22 * determine if an object a is differnt from
...@@ -374,6 +375,7 @@ class HlsHandler extends Component { ...@@ -374,6 +375,7 @@ class HlsHandler extends Component {
374 this.options_.tech = this.tech_; 375 this.options_.tech = this.tech_;
375 this.options_.externHls = Hls; 376 this.options_.externHls = Hls;
376 this.masterPlaylistController_ = new MasterPlaylistController(this.options_); 377 this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
378 this.gapSkipper_ = new GapSkipper(this.options_);
377 379
378 // `this` in selectPlaylist should be the HlsHandler for backwards 380 // `this` in selectPlaylist should be the HlsHandler for backwards
379 // compatibility with < v2 381 // compatibility with < v2
...@@ -542,6 +544,7 @@ class HlsHandler extends Component { ...@@ -542,6 +544,7 @@ class HlsHandler extends Component {
542 if (this.masterPlaylistController_) { 544 if (this.masterPlaylistController_) {
543 this.masterPlaylistController_.dispose(); 545 this.masterPlaylistController_.dispose();
544 } 546 }
547 this.gapSkipper_.dispose();
545 this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_); 548 this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_);
546 super.dispose(); 549 super.dispose();
547 } 550 }
......
1 import videojs from 'video.js';
2 import QUnit from 'qunit';
3 import {
4 useFakeEnvironment,
5 useFakeMediaSource,
6 createPlayer,
7 openMediaSource,
8 standardXHRResponse
9 } from './test-helpers.js';
10
11 QUnit.module('GapSkipper', {
12 beforeEach() {
13 this.env = useFakeEnvironment();
14 this.requests = this.env.requests;
15 this.mse = useFakeMediaSource();
16 this.clock = this.env.clock;
17 this.old = {};
18
19 // setup a player
20 this.player = createPlayer();
21 },
22
23 afterEach() {
24 this.env.restore();
25 this.mse.restore();
26 this.player.dispose();
27 }
28 });
29
30 QUnit.test('skips over gap in firefox with waiting event', function() {
31
32 this.player.autoplay(true);
33
34 // create a buffer with a gap between 10 & 20 seconds
35 this.player.tech_.buffered = function() {
36 return videojs.createTimeRanges([[0, 10], [20, 30]]);
37 };
38
39 // set an arbitrary source
40 this.player.src({
41 src: 'master.m3u8',
42 type: 'application/vnd.apple.mpegurl'
43 });
44
45 // start playback normally
46 this.player.tech_.triggerReady();
47 this.clock.tick(1);
48 standardXHRResponse(this.requests.shift());
49 openMediaSource(this.player, this.clock);
50 this.player.tech_.trigger('play');
51 this.player.tech_.trigger('playing');
52 this.clock.tick(1);
53
54 // seek to 10 seconds and wait 12 seconds
55 this.player.currentTime(10);
56 this.player.tech_.trigger('waiting');
57 this.clock.tick(12000);
58
59 // check that player jumped the gap
60 QUnit.equal(Math.round(this.player.currentTime()),
61 20, 'Player seeked over gap after timer');
62
63 });
64
65 QUnit.test('skips over gap in chrome without waiting event', function() {
66
67 this.player.autoplay(true);
68
69 // create a buffer with a gap between 10 & 20 seconds
70 this.player.tech_.buffered = function() {
71 return videojs.createTimeRanges([[0, 10], [20, 30]]);
72 };
73
74 // set an arbitrary source
75 this.player.src({
76 src: 'master.m3u8',
77 type: 'application/vnd.apple.mpegurl'
78 });
79
80 // start playback normally
81 this.player.tech_.triggerReady();
82 this.clock.tick(1);
83 standardXHRResponse(this.requests.shift());
84 openMediaSource(this.player, this.clock);
85 this.player.tech_.trigger('play');
86 this.player.tech_.trigger('playing');
87 this.clock.tick(1);
88
89 // seek to 10 seconds & simulate chrome waiting event
90 this.player.currentTime(10);
91 for (let i = 0; i < 10; i++) {
92 this.player.tech_.trigger('timeupdate');
93 }
94 this.clock.tick(2000);
95
96 // checks that player doesn't seek before timer expires
97 QUnit.equal(this.player.currentTime(), 10, 'Player doesnt seek over gap pre-timer');
98 this.clock.tick(10000);
99
100 // check that player jumped the gap
101 QUnit.equal(Math.round(this.player.currentTime()),
102 20, 'Player seeked over gap after timer');
103
104 });