UNPKG

14.5 kBJavaScriptView Raw
1/**
2 * @file vtt-segment-loader.js
3 */
4import SegmentLoader from './segment-loader';
5import videojs from 'video.js';
6import window from 'global/window';
7import { removeCuesFromTrack, removeDuplicateCuesFromTrack } from './util/text-tracks';
8import { initSegmentId } from './bin-utils';
9import { uint8ToUtf8 } from './util/string';
10import { REQUEST_ERRORS } from './media-segment-request';
11import { ONE_SECOND_IN_TS } from 'mux.js/lib/utils/clock';
12
13const VTT_LINE_TERMINATORS =
14 new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0)));
15
16class NoVttJsError extends Error {
17 constructor() {
18 super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.');
19 }
20}
21
22/**
23 * An object that manages segment loading and appending.
24 *
25 * @class VTTSegmentLoader
26 * @param {Object} options required and optional options
27 * @extends videojs.EventTarget
28 */
29export default class VTTSegmentLoader extends SegmentLoader {
30 constructor(settings, options = {}) {
31 super(settings, options);
32
33 // SegmentLoader requires a MediaSource be specified or it will throw an error;
34 // however, VTTSegmentLoader has no need of a media source, so delete the reference
35 this.mediaSource_ = null;
36
37 this.subtitlesTrack_ = null;
38
39 this.loaderType_ = 'subtitle';
40
41 this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks;
42
43 this.loadVttJs = settings.loadVttJs;
44
45 // The VTT segment will have its own time mappings. Saving VTT segment timing info in
46 // the sync controller leads to improper behavior.
47 this.shouldSaveSegmentTimingInfo_ = false;
48 }
49
50 createTransmuxer_() {
51 // don't need to transmux any subtitles
52 return null;
53 }
54
55 /**
56 * Indicates which time ranges are buffered
57 *
58 * @return {TimeRange}
59 * TimeRange object representing the current buffered ranges
60 */
61 buffered_() {
62 if (!this.subtitlesTrack_ || !this.subtitlesTrack_.cues || !this.subtitlesTrack_.cues.length) {
63 return videojs.createTimeRanges();
64 }
65
66 const cues = this.subtitlesTrack_.cues;
67 const start = cues[0].startTime;
68 const end = cues[cues.length - 1].startTime;
69
70 return videojs.createTimeRanges([[start, end]]);
71 }
72
73 /**
74 * Gets and sets init segment for the provided map
75 *
76 * @param {Object} map
77 * The map object representing the init segment to get or set
78 * @param {boolean=} set
79 * If true, the init segment for the provided map should be saved
80 * @return {Object}
81 * map object for desired init segment
82 */
83 initSegmentForMap(map, set = false) {
84 if (!map) {
85 return null;
86 }
87
88 const id = initSegmentId(map);
89 let storedMap = this.initSegments_[id];
90
91 if (set && !storedMap && map.bytes) {
92 // append WebVTT line terminators to the media initialization segment if it exists
93 // to follow the WebVTT spec (https://w3c.github.io/webvtt/#file-structure) that
94 // requires two or more WebVTT line terminators between the WebVTT header and the
95 // rest of the file
96 const combinedByteLength = VTT_LINE_TERMINATORS.byteLength + map.bytes.byteLength;
97 const combinedSegment = new Uint8Array(combinedByteLength);
98
99 combinedSegment.set(map.bytes);
100 combinedSegment.set(VTT_LINE_TERMINATORS, map.bytes.byteLength);
101
102 this.initSegments_[id] = storedMap = {
103 resolvedUri: map.resolvedUri,
104 byterange: map.byterange,
105 bytes: combinedSegment
106 };
107 }
108
109 return storedMap || map;
110 }
111
112 /**
113 * Returns true if all configuration required for loading is present, otherwise false.
114 *
115 * @return {boolean} True if the all configuration is ready for loading
116 * @private
117 */
118 couldBeginLoading_() {
119 return this.playlist_ &&
120 this.subtitlesTrack_ &&
121 !this.paused();
122 }
123
124 /**
125 * Once all the starting parameters have been specified, begin
126 * operation. This method should only be invoked from the INIT
127 * state.
128 *
129 * @private
130 */
131 init_() {
132 this.state = 'READY';
133 this.resetEverything();
134 return this.monitorBuffer_();
135 }
136
137 /**
138 * Set a subtitle track on the segment loader to add subtitles to
139 *
140 * @param {TextTrack=} track
141 * The text track to add loaded subtitles to
142 * @return {TextTrack}
143 * Returns the subtitles track
144 */
145 track(track) {
146 if (typeof track === 'undefined') {
147 return this.subtitlesTrack_;
148 }
149
150 this.subtitlesTrack_ = track;
151
152 // if we were unpaused but waiting for a sourceUpdater, start
153 // buffering now
154 if (this.state === 'INIT' && this.couldBeginLoading_()) {
155 this.init_();
156 }
157
158 return this.subtitlesTrack_;
159 }
160
161 /**
162 * Remove any data in the source buffer between start and end times
163 *
164 * @param {number} start - the start time of the region to remove from the buffer
165 * @param {number} end - the end time of the region to remove from the buffer
166 */
167 remove(start, end) {
168 removeCuesFromTrack(start, end, this.subtitlesTrack_);
169 }
170
171 /**
172 * fill the buffer with segements unless the sourceBuffers are
173 * currently updating
174 *
175 * Note: this function should only ever be called by monitorBuffer_
176 * and never directly
177 *
178 * @private
179 */
180 fillBuffer_() {
181 // see if we need to begin loading immediately
182 const segmentInfo = this.chooseNextRequest_();
183
184 if (!segmentInfo) {
185 return;
186 }
187
188 if (this.syncController_.timestampOffsetForTimeline(segmentInfo.timeline) === null) {
189 // We don't have the timestamp offset that we need to sync subtitles.
190 // Rerun on a timestamp offset or user interaction.
191 const checkTimestampOffset = () => {
192 this.state = 'READY';
193 if (!this.paused()) {
194 // if not paused, queue a buffer check as soon as possible
195 this.monitorBuffer_();
196 }
197 };
198
199 this.syncController_.one('timestampoffset', checkTimestampOffset);
200 this.state = 'WAITING_ON_TIMELINE';
201 return;
202 }
203
204 this.loadSegment_(segmentInfo);
205 }
206
207 // never set a timestamp offset for vtt segments.
208 timestampOffsetForSegment_() {
209 return null;
210 }
211
212 chooseNextRequest_() {
213 return this.skipEmptySegments_(super.chooseNextRequest_());
214 }
215
216 /**
217 * Prevents the segment loader from requesting segments we know contain no subtitles
218 * by walking forward until we find the next segment that we don't know whether it is
219 * empty or not.
220 *
221 * @param {Object} segmentInfo
222 * a segment info object that describes the current segment
223 * @return {Object}
224 * a segment info object that describes the current segment
225 */
226 skipEmptySegments_(segmentInfo) {
227 while (segmentInfo && segmentInfo.segment.empty) {
228 // stop at the last possible segmentInfo
229 if (segmentInfo.mediaIndex + 1 >= segmentInfo.playlist.segments.length) {
230 segmentInfo = null;
231 break;
232 }
233 segmentInfo = this.generateSegmentInfo_({
234 playlist: segmentInfo.playlist,
235 mediaIndex: segmentInfo.mediaIndex + 1,
236 startOfSegment: segmentInfo.startOfSegment + segmentInfo.duration,
237 isSyncRequest: segmentInfo.isSyncRequest
238 });
239 }
240 return segmentInfo;
241 }
242
243 stopForError(error) {
244 this.error(error);
245 this.state = 'READY';
246 this.pause();
247 this.trigger('error');
248 }
249
250 /**
251 * append a decrypted segement to the SourceBuffer through a SourceUpdater
252 *
253 * @private
254 */
255 segmentRequestFinished_(error, simpleSegment, result) {
256 if (!this.subtitlesTrack_) {
257 this.state = 'READY';
258 return;
259 }
260
261 this.saveTransferStats_(simpleSegment.stats);
262
263 // the request was aborted
264 if (!this.pendingSegment_) {
265 this.state = 'READY';
266 this.mediaRequestsAborted += 1;
267 return;
268 }
269
270 if (error) {
271 if (error.code === REQUEST_ERRORS.TIMEOUT) {
272 this.handleTimeout_();
273 }
274
275 if (error.code === REQUEST_ERRORS.ABORTED) {
276 this.mediaRequestsAborted += 1;
277 } else {
278 this.mediaRequestsErrored += 1;
279 }
280
281 this.stopForError(error);
282 return;
283 }
284
285 const segmentInfo = this.pendingSegment_;
286
287 // although the VTT segment loader bandwidth isn't really used, it's good to
288 // maintain functionality between segment loaders
289 this.saveBandwidthRelatedStats_(segmentInfo.duration, simpleSegment.stats);
290
291 // if this request included a segment key, save that data in the cache
292 if (simpleSegment.key) {
293 this.segmentKey(simpleSegment.key, true);
294 }
295
296 this.state = 'APPENDING';
297
298 // used for tests
299 this.trigger('appending');
300
301 const segment = segmentInfo.segment;
302
303 if (segment.map) {
304 segment.map.bytes = simpleSegment.map.bytes;
305 }
306 segmentInfo.bytes = simpleSegment.bytes;
307
308 // Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading
309 if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs === 'function') {
310 this.state = 'WAITING_ON_VTTJS';
311 // should be fine to call multiple times
312 // script will be loaded once but multiple listeners will be added to the queue, which is expected.
313 this.loadVttJs()
314 .then(
315 () => this.segmentRequestFinished_(error, simpleSegment, result),
316 () => this.stopForError({ message: 'Error loading vtt.js' })
317 );
318 return;
319 }
320
321 segment.requested = true;
322
323 try {
324 this.parseVTTCues_(segmentInfo);
325 } catch (e) {
326 this.stopForError({
327 message: e.message
328 });
329 return;
330 }
331
332 this.updateTimeMapping_(
333 segmentInfo,
334 this.syncController_.timelines[segmentInfo.timeline],
335 this.playlist_
336 );
337
338 if (segmentInfo.cues.length) {
339 segmentInfo.timingInfo = {
340 start: segmentInfo.cues[0].startTime,
341 end: segmentInfo.cues[segmentInfo.cues.length - 1].endTime
342 };
343 } else {
344 segmentInfo.timingInfo = {
345 start: segmentInfo.startOfSegment,
346 end: segmentInfo.startOfSegment + segmentInfo.duration
347 };
348 }
349
350 if (segmentInfo.isSyncRequest) {
351 this.trigger('syncinfoupdate');
352 this.pendingSegment_ = null;
353 this.state = 'READY';
354 return;
355 }
356
357 segmentInfo.byteLength = segmentInfo.bytes.byteLength;
358
359 this.mediaSecondsLoaded += segment.duration;
360
361 // Create VTTCue instances for each cue in the new segment and add them to
362 // the subtitle track
363 segmentInfo.cues.forEach((cue) => {
364 this.subtitlesTrack_.addCue(this.featuresNativeTextTracks_ ?
365 new window.VTTCue(cue.startTime, cue.endTime, cue.text) :
366 cue);
367 });
368
369 // Remove any duplicate cues from the subtitle track. The WebVTT spec allows
370 // cues to have identical time-intervals, but if the text is also identical
371 // we can safely assume it is a duplicate that can be removed (ex. when a cue
372 // "overlaps" VTT segments)
373 removeDuplicateCuesFromTrack(this.subtitlesTrack_);
374
375 this.handleAppendsDone_();
376 }
377
378 handleData_() {
379 // noop as we shouldn't be getting video/audio data captions
380 // that we do not support here.
381 }
382 updateTimingInfoEnd_() {
383 // noop
384 }
385
386 /**
387 * Uses the WebVTT parser to parse the segment response
388 *
389 * @throws NoVttJsError
390 *
391 * @param {Object} segmentInfo
392 * a segment info object that describes the current segment
393 * @private
394 */
395 parseVTTCues_(segmentInfo) {
396 let decoder;
397 let decodeBytesToString = false;
398
399 if (typeof window.WebVTT !== 'function') {
400 // caller is responsible for exception handling.
401 throw new NoVttJsError();
402 }
403
404 if (typeof window.TextDecoder === 'function') {
405 decoder = new window.TextDecoder('utf8');
406 } else {
407 decoder = window.WebVTT.StringDecoder();
408 decodeBytesToString = true;
409 }
410
411 const parser = new window.WebVTT.Parser(
412 window,
413 window.vttjs,
414 decoder
415 );
416
417 segmentInfo.cues = [];
418 segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 };
419
420 parser.oncue = segmentInfo.cues.push.bind(segmentInfo.cues);
421 parser.ontimestampmap = (map) => {
422 segmentInfo.timestampmap = map;
423 };
424 parser.onparsingerror = (error) => {
425 videojs.log.warn('Error encountered when parsing cues: ' + error.message);
426 };
427
428 if (segmentInfo.segment.map) {
429 let mapData = segmentInfo.segment.map.bytes;
430
431 if (decodeBytesToString) {
432 mapData = uint8ToUtf8(mapData);
433 }
434
435 parser.parse(mapData);
436 }
437
438 let segmentData = segmentInfo.bytes;
439
440 if (decodeBytesToString) {
441 segmentData = uint8ToUtf8(segmentData);
442 }
443
444 parser.parse(segmentData);
445 parser.flush();
446 }
447
448 /**
449 * Updates the start and end times of any cues parsed by the WebVTT parser using
450 * the information parsed from the X-TIMESTAMP-MAP header and a TS to media time mapping
451 * from the SyncController
452 *
453 * @param {Object} segmentInfo
454 * a segment info object that describes the current segment
455 * @param {Object} mappingObj
456 * object containing a mapping from TS to media time
457 * @param {Object} playlist
458 * the playlist object containing the segment
459 * @private
460 */
461 updateTimeMapping_(segmentInfo, mappingObj, playlist) {
462 const segment = segmentInfo.segment;
463
464 if (!mappingObj) {
465 // If the sync controller does not have a mapping of TS to Media Time for the
466 // timeline, then we don't have enough information to update the cue
467 // start/end times
468 return;
469 }
470
471 if (!segmentInfo.cues.length) {
472 // If there are no cues, we also do not have enough information to figure out
473 // segment timing. Mark that the segment contains no cues so we don't re-request
474 // an empty segment.
475 segment.empty = true;
476 return;
477 }
478
479 const timestampmap = segmentInfo.timestampmap;
480 const diff = (timestampmap.MPEGTS / ONE_SECOND_IN_TS) - timestampmap.LOCAL + mappingObj.mapping;
481
482 segmentInfo.cues.forEach((cue) => {
483 // First convert cue time to TS time using the timestamp-map provided within the vtt
484 cue.startTime += diff;
485 cue.endTime += diff;
486 });
487
488 if (!playlist.syncInfo) {
489 const firstStart = segmentInfo.cues[0].startTime;
490 const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime;
491
492 playlist.syncInfo = {
493 mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
494 time: Math.min(firstStart, lastStart - segment.duration)
495 };
496 }
497 }
498}