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
Showing
3 changed files
with
288 additions
and
0 deletions
src/gap-skipper.js
0 → 100644
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 | } | ... | ... |
test/gap-skipper.test.js
0 → 100644
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 | }); |
-
Please register or sign in to post a comment