UNPKG

11.6 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 */
124 track(track) {
125 this.subtitlesTrack_ = track;
126
127 // if we were unpaused but waiting for a sourceUpdater, start
128 // buffering now
129 if (this.state === 'INIT' && this.couldBeginLoading_()) {
130 this.init_();
131 }
132 }
133
134 /**
135 * Remove any data in the source buffer between start and end times
136 * @param {Number} start - the start time of the region to remove from the buffer
137 * @param {Number} end - the end time of the region to remove from the buffer
138 */
139 remove(start, end) {
140 removeCuesFromTrack(start, end, this.subtitlesTrack_);
141 }
142
143 /**
144 * fill the buffer with segements unless the sourceBuffers are
145 * currently updating
146 *
147 * Note: this function should only ever be called by monitorBuffer_
148 * and never directly
149 *
150 * @private
151 */
152 fillBuffer_() {
153 if (!this.syncPoint_) {
154 this.syncPoint_ = this.syncController_.getSyncPoint(this.playlist_,
155 this.duration_(),
156 this.currentTimeline_,
157 this.currentTime_());
158 }
159
160 // see if we need to begin loading immediately
161 let segmentInfo = this.checkBuffer_(this.buffered_(),
162 this.playlist_,
163 this.mediaIndex,
164 this.hasPlayed_(),
165 this.currentTime_(),
166 this.syncPoint_);
167
168 segmentInfo = this.skipEmptySegments_(segmentInfo);
169
170 if (!segmentInfo) {
171 return;
172 }
173
174 if (this.syncController_.timestampOffsetForTimeline(segmentInfo.timeline) === null) {
175 // We don't have the timestamp offset that we need to sync subtitles.
176 // Rerun on a timestamp offset or user interaction.
177 let checkTimestampOffset = () => {
178 this.state = 'READY';
179 if (!this.paused()) {
180 // if not paused, queue a buffer check as soon as possible
181 this.monitorBuffer_();
182 }
183 };
184
185 this.syncController_.one('timestampoffset', checkTimestampOffset);
186 this.state = 'WAITING_ON_TIMELINE';
187 return;
188 }
189
190 this.loadSegment_(segmentInfo);
191 }
192
193 /**
194 * Prevents the segment loader from requesting segments we know contain no subtitles
195 * by walking forward until we find the next segment that we don't know whether it is
196 * empty or not.
197 *
198 * @param {Object} segmentInfo
199 * a segment info object that describes the current segment
200 * @return {Object}
201 * a segment info object that describes the current segment
202 */
203 skipEmptySegments_(segmentInfo) {
204 while (segmentInfo && segmentInfo.segment.empty) {
205 segmentInfo = this.generateSegmentInfo_(
206 segmentInfo.playlist,
207 segmentInfo.mediaIndex + 1,
208 segmentInfo.startOfSegment + segmentInfo.duration,
209 segmentInfo.isSyncRequest);
210 }
211 return segmentInfo;
212 }
213
214 /**
215 * append a decrypted segement to the SourceBuffer through a SourceUpdater
216 *
217 * @private
218 */
219 handleSegment_() {
220 if (!this.pendingSegment_) {
221 this.state = 'READY';
222 return;
223 }
224
225 this.state = 'APPENDING';
226
227 let segmentInfo = this.pendingSegment_;
228 let segment = segmentInfo.segment;
229
230 // Make sure that vttjs has loaded, otherwise, wait till it finished loading
231 if (typeof window.WebVTT !== 'function' &&
232 this.subtitlesTrack_ &&
233 this.subtitlesTrack_.tech_) {
234
235 const loadHandler = () => {
236 this.handleSegment_();
237 };
238
239 this.state = 'WAITING_ON_VTTJS';
240 this.subtitlesTrack_.tech_.one('vttjsloaded', loadHandler);
241 this.subtitlesTrack_.tech_.one('vttjserror', () => {
242 this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler);
243 this.error({
244 message: 'Error loading vtt.js'
245 });
246 this.state = 'READY';
247 this.pause();
248 this.trigger('error');
249 });
250
251 return;
252 }
253
254 segment.requested = true;
255
256 try {
257 this.parseVTTCues_(segmentInfo);
258 } catch (e) {
259 this.error({
260 message: e.message
261 });
262 this.state = 'READY';
263 this.pause();
264 return this.trigger('error');
265 }
266
267 this.updateTimeMapping_(segmentInfo,
268 this.syncController_.timelines[segmentInfo.timeline],
269 this.playlist_);
270
271 if (segmentInfo.isSyncRequest) {
272 this.trigger('syncinfoupdate');
273 this.pendingSegment_ = null;
274 this.state = 'READY';
275 return;
276 }
277
278 segmentInfo.byteLength = segmentInfo.bytes.byteLength;
279
280 this.mediaSecondsLoaded += segment.duration;
281
282 segmentInfo.cues.forEach((cue) => {
283 this.subtitlesTrack_.addCue(cue);
284 });
285
286 this.handleUpdateEnd_();
287 }
288
289 /**
290 * Uses the WebVTT parser to parse the segment response
291 *
292 * @param {Object} segmentInfo
293 * a segment info object that describes the current segment
294 * @private
295 */
296 parseVTTCues_(segmentInfo) {
297 let decoder;
298 let decodeBytesToString = false;
299
300 if (typeof window.TextDecoder === 'function') {
301 decoder = new window.TextDecoder('utf8');
302 } else {
303 decoder = window.WebVTT.StringDecoder();
304 decodeBytesToString = true;
305 }
306
307 const parser = new window.WebVTT.Parser(window,
308 window.vttjs,
309 decoder);
310
311 segmentInfo.cues = [];
312 segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 };
313
314 parser.oncue = segmentInfo.cues.push.bind(segmentInfo.cues);
315 parser.ontimestampmap = (map) => segmentInfo.timestampmap = map;
316 parser.onparsingerror = (error) => {
317 videojs.log.warn('Error encountered when parsing cues: ' + error.message);
318 };
319
320 if (segmentInfo.segment.map) {
321 let mapData = segmentInfo.segment.map.bytes;
322
323 if (decodeBytesToString) {
324 mapData = uintToString(mapData);
325 }
326
327 parser.parse(mapData);
328 }
329
330 let segmentData = segmentInfo.bytes;
331
332 if (decodeBytesToString) {
333 segmentData = uintToString(segmentData);
334 }
335
336 parser.parse(segmentData);
337 parser.flush();
338 }
339
340 /**
341 * Updates the start and end times of any cues parsed by the WebVTT parser using
342 * the information parsed from the X-TIMESTAMP-MAP header and a TS to media time mapping
343 * from the SyncController
344 *
345 * @param {Object} segmentInfo
346 * a segment info object that describes the current segment
347 * @param {Object} mappingObj
348 * object containing a mapping from TS to media time
349 * @param {Object} playlist
350 * the playlist object containing the segment
351 * @private
352 */
353 updateTimeMapping_(segmentInfo, mappingObj, playlist) {
354 const segment = segmentInfo.segment;
355
356 if (!mappingObj) {
357 // If the sync controller does not have a mapping of TS to Media Time for the
358 // timeline, then we don't have enough information to update the cue
359 // start/end times
360 return;
361 }
362
363 if (!segmentInfo.cues.length) {
364 // If there are no cues, we also do not have enough information to figure out
365 // segment timing. Mark that the segment contains no cues so we don't re-request
366 // an empty segment.
367 segment.empty = true;
368 return;
369 }
370
371 const timestampmap = segmentInfo.timestampmap;
372 const diff = (timestampmap.MPEGTS / 90000) - timestampmap.LOCAL + mappingObj.mapping;
373
374 segmentInfo.cues.forEach((cue) => {
375 // First convert cue time to TS time using the timestamp-map provided within the vtt
376 cue.startTime += diff;
377 cue.endTime += diff;
378 });
379
380 if (!playlist.syncInfo) {
381 const firstStart = segmentInfo.cues[0].startTime;
382 const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime;
383
384 playlist.syncInfo = {
385 mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
386 time: Math.min(firstStart, lastStart - segment.duration)
387 };
388 }
389 }
390}