UNPKG

12 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 from
8 'videojs-contrib-media-sources/es5/remove-cues-from-track.js';
9import { initSegmentId } from './bin-utils';
10
11const VTT_LINE_TERMINATORS =
12 new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0)));
13
14const uintToString = function(uintArray) {
15 return String.fromCharCode.apply(null, uintArray);
16};
17
18/**
19 * An object that manages segment loading and appending.
20 *
21 * @class VTTSegmentLoader
22 * @param {Object} options required and optional options
23 * @extends videojs.EventTarget
24 */
25export default class VTTSegmentLoader extends SegmentLoader {
26 constructor(settings, options = {}) {
27 super(settings, options);
28
29 // SegmentLoader requires a MediaSource be specified or it will throw an error;
30 // however, VTTSegmentLoader has no need of a media source, so delete the reference
31 this.mediaSource_ = null;
32
33 this.subtitlesTrack_ = null;
34 }
35
36 /**
37 * Indicates which time ranges are buffered
38 *
39 * @return {TimeRange}
40 * TimeRange object representing the current buffered ranges
41 */
42 buffered_() {
43 if (!this.subtitlesTrack_ || !this.subtitlesTrack_.cues.length) {
44 return videojs.createTimeRanges();
45 }
46
47 const cues = this.subtitlesTrack_.cues;
48 let start = cues[0].startTime;
49 let end = cues[cues.length - 1].startTime;
50
51 return videojs.createTimeRanges([[start, end]]);
52 }
53
54 /**
55 * Gets and sets init segment for the provided map
56 *
57 * @param {Object} map
58 * The map object representing the init segment to get or set
59 * @param {Boolean=} set
60 * If true, the init segment for the provided map should be saved
61 * @return {Object}
62 * map object for desired init segment
63 */
64 initSegment(map, set = false) {
65 if (!map) {
66 return null;
67 }
68
69 const id = initSegmentId(map);
70 let storedMap = this.initSegments_[id];
71
72 if (set && !storedMap && map.bytes) {
73 // append WebVTT line terminators to the media initialization segment if it exists
74 // to follow the WebVTT spec (https://w3c.github.io/webvtt/#file-structure) that
75 // requires two or more WebVTT line terminators between the WebVTT header and the
76 // rest of the file
77 const combinedByteLength = VTT_LINE_TERMINATORS.byteLength + map.bytes.byteLength;
78 const combinedSegment = new Uint8Array(combinedByteLength);
79
80 combinedSegment.set(map.bytes);
81 combinedSegment.set(VTT_LINE_TERMINATORS, map.bytes.byteLength);
82
83 this.initSegments_[id] = storedMap = {
84 resolvedUri: map.resolvedUri,
85 byterange: map.byterange,
86 bytes: combinedSegment
87 };
88 }
89
90 return storedMap || map;
91 }
92
93 /**
94 * Returns true if all configuration required for loading is present, otherwise false.
95 *
96 * @return {Boolean} True if the all configuration is ready for loading
97 * @private
98 */
99 couldBeginLoading_() {
100 return this.playlist_ &&
101 this.subtitlesTrack_ &&
102 !this.paused();
103 }
104
105 /**
106 * Once all the starting parameters have been specified, begin
107 * operation. This method should only be invoked from the INIT
108 * state.
109 *
110 * @private
111 */
112 init_() {
113 this.state = 'READY';
114 this.resetEverything();
115 return this.monitorBuffer_();
116 }
117
118 /**
119 * Set a subtitle track on the segment loader to add subtitles to
120 *
121 * @param {TextTrack=} track
122 * The text track to add loaded subtitles to
123 * @return {TextTrack}
124 * Returns the subtitles track
125 */
126 track(track) {
127 if (typeof track === 'undefined') {
128 return this.subtitlesTrack_;
129 }
130
131 this.subtitlesTrack_ = track;
132
133 // if we were unpaused but waiting for a sourceUpdater, start
134 // buffering now
135 if (this.state === 'INIT' && this.couldBeginLoading_()) {
136 this.init_();
137 }
138
139 return this.subtitlesTrack_;
140 }
141
142 /**
143 * Remove any data in the source buffer between start and end times
144 * @param {Number} start - the start time of the region to remove from the buffer
145 * @param {Number} end - the end time of the region to remove from the buffer
146 */
147 remove(start, end) {
148 removeCuesFromTrack(start, end, this.subtitlesTrack_);
149 }
150
151 /**
152 * fill the buffer with segements unless the sourceBuffers are
153 * currently updating
154 *
155 * Note: this function should only ever be called by monitorBuffer_
156 * and never directly
157 *
158 * @private
159 */
160 fillBuffer_() {
161 if (!this.syncPoint_) {
162 this.syncPoint_ = this.syncController_.getSyncPoint(this.playlist_,
163 this.duration_(),
164 this.currentTimeline_,
165 this.currentTime_());
166 }
167
168 // see if we need to begin loading immediately
169 let segmentInfo = this.checkBuffer_(this.buffered_(),
170 this.playlist_,
171 this.mediaIndex,
172 this.hasPlayed_(),
173 this.currentTime_(),
174 this.syncPoint_);
175
176 segmentInfo = this.skipEmptySegments_(segmentInfo);
177
178 if (!segmentInfo) {
179 return;
180 }
181
182 if (this.syncController_.timestampOffsetForTimeline(segmentInfo.timeline) === null) {
183 // We don't have the timestamp offset that we need to sync subtitles.
184 // Rerun on a timestamp offset or user interaction.
185 let checkTimestampOffset = () => {
186 this.state = 'READY';
187 if (!this.paused()) {
188 // if not paused, queue a buffer check as soon as possible
189 this.monitorBuffer_();
190 }
191 };
192
193 this.syncController_.one('timestampoffset', checkTimestampOffset);
194 this.state = 'WAITING_ON_TIMELINE';
195 return;
196 }
197
198 this.loadSegment_(segmentInfo);
199 }
200
201 /**
202 * Prevents the segment loader from requesting segments we know contain no subtitles
203 * by walking forward until we find the next segment that we don't know whether it is
204 * empty or not.
205 *
206 * @param {Object} segmentInfo
207 * a segment info object that describes the current segment
208 * @return {Object}
209 * a segment info object that describes the current segment
210 */
211 skipEmptySegments_(segmentInfo) {
212 while (segmentInfo && segmentInfo.segment.empty) {
213 segmentInfo = this.generateSegmentInfo_(
214 segmentInfo.playlist,
215 segmentInfo.mediaIndex + 1,
216 segmentInfo.startOfSegment + segmentInfo.duration,
217 segmentInfo.isSyncRequest);
218 }
219 return segmentInfo;
220 }
221
222 /**
223 * append a decrypted segement to the SourceBuffer through a SourceUpdater
224 *
225 * @private
226 */
227 handleSegment_() {
228 if (!this.pendingSegment_ || !this.subtitlesTrack_) {
229 this.state = 'READY';
230 return;
231 }
232
233 this.state = 'APPENDING';
234
235 let segmentInfo = this.pendingSegment_;
236 let segment = segmentInfo.segment;
237
238 // Make sure that vttjs has loaded, otherwise, wait till it finished loading
239 if (typeof window.WebVTT !== 'function' &&
240 this.subtitlesTrack_ &&
241 this.subtitlesTrack_.tech_) {
242
243 const loadHandler = () => {
244 this.handleSegment_();
245 };
246
247 this.state = 'WAITING_ON_VTTJS';
248 this.subtitlesTrack_.tech_.one('vttjsloaded', loadHandler);
249 this.subtitlesTrack_.tech_.one('vttjserror', () => {
250 this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler);
251 this.error({
252 message: 'Error loading vtt.js'
253 });
254 this.state = 'READY';
255 this.pause();
256 this.trigger('error');
257 });
258
259 return;
260 }
261
262 segment.requested = true;
263
264 try {
265 this.parseVTTCues_(segmentInfo);
266 } catch (e) {
267 this.error({
268 message: e.message
269 });
270 this.state = 'READY';
271 this.pause();
272 return this.trigger('error');
273 }
274
275 this.updateTimeMapping_(segmentInfo,
276 this.syncController_.timelines[segmentInfo.timeline],
277 this.playlist_);
278
279 if (segmentInfo.isSyncRequest) {
280 this.trigger('syncinfoupdate');
281 this.pendingSegment_ = null;
282 this.state = 'READY';
283 return;
284 }
285
286 segmentInfo.byteLength = segmentInfo.bytes.byteLength;
287
288 this.mediaSecondsLoaded += segment.duration;
289
290 if (segmentInfo.cues.length) {
291 // remove any overlapping cues to prevent doubling
292 this.remove(segmentInfo.cues[0].endTime,
293 segmentInfo.cues[segmentInfo.cues.length - 1].endTime);
294 }
295
296 segmentInfo.cues.forEach((cue) => {
297 this.subtitlesTrack_.addCue(cue);
298 });
299
300 this.handleUpdateEnd_();
301 }
302
303 /**
304 * Uses the WebVTT parser to parse the segment response
305 *
306 * @param {Object} segmentInfo
307 * a segment info object that describes the current segment
308 * @private
309 */
310 parseVTTCues_(segmentInfo) {
311 let decoder;
312 let decodeBytesToString = false;
313
314 if (typeof window.TextDecoder === 'function') {
315 decoder = new window.TextDecoder('utf8');
316 } else {
317 decoder = window.WebVTT.StringDecoder();
318 decodeBytesToString = true;
319 }
320
321 const parser = new window.WebVTT.Parser(window,
322 window.vttjs,
323 decoder);
324
325 segmentInfo.cues = [];
326 segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 };
327
328 parser.oncue = segmentInfo.cues.push.bind(segmentInfo.cues);
329 parser.ontimestampmap = (map) => segmentInfo.timestampmap = map;
330 parser.onparsingerror = (error) => {
331 videojs.log.warn('Error encountered when parsing cues: ' + error.message);
332 };
333
334 if (segmentInfo.segment.map) {
335 let mapData = segmentInfo.segment.map.bytes;
336
337 if (decodeBytesToString) {
338 mapData = uintToString(mapData);
339 }
340
341 parser.parse(mapData);
342 }
343
344 let segmentData = segmentInfo.bytes;
345
346 if (decodeBytesToString) {
347 segmentData = uintToString(segmentData);
348 }
349
350 parser.parse(segmentData);
351 parser.flush();
352 }
353
354 /**
355 * Updates the start and end times of any cues parsed by the WebVTT parser using
356 * the information parsed from the X-TIMESTAMP-MAP header and a TS to media time mapping
357 * from the SyncController
358 *
359 * @param {Object} segmentInfo
360 * a segment info object that describes the current segment
361 * @param {Object} mappingObj
362 * object containing a mapping from TS to media time
363 * @param {Object} playlist
364 * the playlist object containing the segment
365 * @private
366 */
367 updateTimeMapping_(segmentInfo, mappingObj, playlist) {
368 const segment = segmentInfo.segment;
369
370 if (!mappingObj) {
371 // If the sync controller does not have a mapping of TS to Media Time for the
372 // timeline, then we don't have enough information to update the cue
373 // start/end times
374 return;
375 }
376
377 if (!segmentInfo.cues.length) {
378 // If there are no cues, we also do not have enough information to figure out
379 // segment timing. Mark that the segment contains no cues so we don't re-request
380 // an empty segment.
381 segment.empty = true;
382 return;
383 }
384
385 const timestampmap = segmentInfo.timestampmap;
386 const diff = (timestampmap.MPEGTS / 90000) - timestampmap.LOCAL + mappingObj.mapping;
387
388 segmentInfo.cues.forEach((cue) => {
389 // First convert cue time to TS time using the timestamp-map provided within the vtt
390 cue.startTime += diff;
391 cue.endTime += diff;
392 });
393
394 if (!playlist.syncInfo) {
395 const firstStart = segmentInfo.cues[0].startTime;
396 const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime;
397
398 playlist.syncInfo = {
399 mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
400 time: Math.min(firstStart, lastStart - segment.duration)
401 };
402 }
403 }
404}