gap-skipper.js
7.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
/**
* @file gap-skipper.js
*/
import Ranges from './ranges';
import videojs from 'video.js';
// Set of events that reset the gap-skipper logic and clear the timeout
const timerCancelEvents = [
'seeking',
'seeked',
'pause',
'playing',
'error'
];
/**
* The gap skipper object handles all scenarios
* where the player runs into the end of a buffered
* region and there is a buffered region ahead.
*
* It then handles the skipping behavior by setting a
* timer to the size (in time) of the gap. This gives
* the hls segment fetcher time to close the gap and
* resume playing before the timer is triggered and
* the gap skipper simply seeks over the gap as a
* last resort to resume playback.
*
* @class GapSkipper
*/
export default class GapSkipper {
/**
* Represents a GapSKipper object.
* @constructor
* @param {object} options an object that includes the tech and settings
*/
constructor(options) {
this.tech_ = options.tech;
this.consecutiveUpdates = 0;
this.lastRecordedTime = null;
this.timer_ = null;
if (options.debug) {
this.logger_ = videojs.log.bind(videojs, 'gap-skipper ->');
}
this.logger_('initialize');
let waitingHandler = ()=> this.waiting_();
let timeupdateHandler = ()=> this.timeupdate_();
let cancelTimerHandler = ()=> this.cancelTimer_();
this.tech_.on('waiting', waitingHandler);
this.tech_.on('timeupdate', timeupdateHandler);
this.tech_.on(timerCancelEvents, cancelTimerHandler);
// Define the dispose function to clean up our events
this.dispose = () => {
this.logger_('dispose');
this.tech_.off('waiting', waitingHandler);
this.tech_.off('timeupdate', timeupdateHandler);
this.tech_.off(timerCancelEvents, cancelTimerHandler);
this.cancelTimer_();
};
}
/**
* Handler for `waiting` events from the player
*
* @private
*/
waiting_() {
if (!this.tech_.seeking()) {
this.setTimer_();
}
}
/**
* The purpose of this function is to emulate the "waiting" event on
* browsers that do not emit it when they are waiting for more
* data to continue playback
*
* @private
*/
timeupdate_() {
if (this.tech_.paused() || this.tech_.seeking()) {
return;
}
let currentTime = this.tech_.currentTime();
if (this.consecutiveUpdates === 5 &&
currentTime === this.lastRecordedTime) {
this.consecutiveUpdates++;
this.waiting_();
} else if (currentTime === this.lastRecordedTime) {
this.consecutiveUpdates++;
} else {
this.consecutiveUpdates = 0;
this.lastRecordedTime = currentTime;
}
}
/**
* Cancels any pending timers and resets the 'timeupdate' mechanism
* designed to detect that we are stalled
*
* @private
*/
cancelTimer_() {
this.consecutiveUpdates = 0;
if (this.timer_) {
this.logger_('cancelTimer_');
clearTimeout(this.timer_);
}
this.timer_ = null;
}
/**
* Timer callback. If playback still has not proceeded, then we seek
* to the start of the next buffered region.
*
* @private
*/
skipTheGap_(scheduledCurrentTime) {
let buffered = this.tech_.buffered();
let currentTime = this.tech_.currentTime();
let nextRange = Ranges.findNextRange(buffered, currentTime);
this.consecutiveUpdates = 0;
this.timer_ = null;
if (nextRange.length === 0 ||
currentTime !== scheduledCurrentTime) {
return;
}
this.logger_('skipTheGap_:',
'currentTime:', currentTime,
'scheduled currentTime:', scheduledCurrentTime,
'nextRange start:', nextRange.start(0));
// only seek if we still have not played
this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR);
}
gapFromVideoUnderflow_(buffered, currentTime) {
// At least in Chrome, if there is a gap in the video buffer, the audio will continue
// playing for ~3 seconds after the video gap starts. This is done to account for
// video buffer underflow/underrun (note that this is not done when there is audio
// buffer underflow/underrun -- in that case the video will stop as soon as it
// encounters the gap, as audio stalls are more noticeable/jarring to a user than
// video stalls). The player's time will reflect the playthrough of audio, so the
// time will appear as if we are in a buffered region, even if we are stuck in a
// "gap."
//
// Example:
// video buffer: 0 => 10.1, 10.2 => 20
// audio buffer: 0 => 20
// overall buffer: 0 => 10.1, 10.2 => 20
// current time: 13
//
// Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
// however, the audio continued playing until it reached ~3 seconds past the gap
// (13 seconds), at which point it stops as well. Since current time is past the
// gap, findNextRange will return no ranges.
//
// To check for this issue, we see if there is a gap that starts somewhere within
// a 3 second range (3 seconds +/- 1 second) back from our current time.
let gaps = Ranges.findGaps(buffered);
for (let i = 0; i < gaps.length; i++) {
let start = gaps.start(i);
let end = gaps.end(i);
// gap is starts no more than 4 seconds back
if (currentTime - start < 4 && currentTime - start > 2) {
return {
start,
end
};
}
}
return null;
}
/**
* Set a timer to skip the unbuffered region.
*
* @private
*/
setTimer_() {
let buffered = this.tech_.buffered();
let currentTime = this.tech_.currentTime();
let nextRange = Ranges.findNextRange(buffered, currentTime);
if (this.timer_ !== null) {
return;
}
if (nextRange.length === 0) {
// Even if there is no available next range, there is still a possibility we are
// stuck in a gap due to video underflow.
let gap = this.gapFromVideoUnderflow_(buffered, currentTime);
if (gap) {
this.logger_('setTimer_:',
'Encountered a gap in video',
'from: ', gap.start,
'to: ', gap.end,
'seeking to current time: ', currentTime);
// Even though the video underflowed and was stuck in a gap, the audio overplayed
// the gap, leading currentTime into a buffered range. Seeking to currentTime
// allows the video to catch up to the audio position without losing any audio
// (only suffering ~3 seconds of frozen video and a pause in audio playback).
this.tech_.setCurrentTime(currentTime);
}
return;
}
let difference = nextRange.start(0) - currentTime;
this.logger_('setTimer_:',
'stopped at:', currentTime,
'setting timer for:', difference,
'seeking to:', nextRange.start(0));
this.timer_ = setTimeout(this.skipTheGap_.bind(this),
difference * 1000,
currentTime);
}
/**
* A debugging logger noop that is set to console.log only if debugging
* is enabled globally
*
* @private
*/
logger_() {}
}