UNPKG

23.2 kBJavaScriptView Raw
1import videojs from 'video.js';
2import PlaylistLoader from './playlist-loader';
3
4const noop = () => {};
5
6/**
7 * Convert the properties of an HLS track into an audioTrackKind.
8 *
9 * @private
10 */
11const audioTrackKind_ = (properties) => {
12 let kind = properties.default ? 'main' : 'alternative';
13
14 if (properties.characteristics &&
15 properties.characteristics.indexOf('public.accessibility.describes-video') >= 0) {
16 kind = 'main-desc';
17 }
18
19 return kind;
20};
21
22/**
23 * Pause provided segment loader and playlist loader if active
24 *
25 * @param {SegmentLoader} segmentLoader
26 * SegmentLoader to pause
27 * @param {Object} mediaType
28 * Active media type
29 * @function stopLoaders
30 */
31export const stopLoaders = (segmentLoader, mediaType) => {
32 segmentLoader.abort();
33 segmentLoader.pause();
34
35 if (mediaType && mediaType.activePlaylistLoader) {
36 mediaType.activePlaylistLoader.pause();
37 mediaType.activePlaylistLoader = null;
38 }
39};
40
41/**
42 * Start loading provided segment loader and playlist loader
43 *
44 * @param {PlaylistLoader} playlistLoader
45 * PlaylistLoader to start loading
46 * @param {Object} mediaType
47 * Active media type
48 * @function startLoaders
49 */
50export const startLoaders = (playlistLoader, mediaType) => {
51 // Segment loader will be started after `loadedmetadata` or `loadedplaylist` from the
52 // playlist loader
53 mediaType.activePlaylistLoader = playlistLoader;
54 playlistLoader.load();
55};
56
57/**
58 * Returns a function to be called when the media group changes. It performs a
59 * non-destructive (preserve the buffer) resync of the SegmentLoader. This is because a
60 * change of group is merely a rendition switch of the same content at another encoding,
61 * rather than a change of content, such as switching audio from English to Spanish.
62 *
63 * @param {String} type
64 * MediaGroup type
65 * @param {Object} settings
66 * Object containing required information for media groups
67 * @return {Function}
68 * Handler for a non-destructive resync of SegmentLoader when the active media
69 * group changes.
70 * @function onGroupChanged
71 */
72export const onGroupChanged = (type, settings) => () => {
73 const {
74 segmentLoaders: {
75 [type]: segmentLoader,
76 main: mainSegmentLoader
77 },
78 mediaTypes: { [type]: mediaType }
79 } = settings;
80 const activeTrack = mediaType.activeTrack();
81 const activeGroup = mediaType.activeGroup(activeTrack);
82 const previousActiveLoader = mediaType.activePlaylistLoader;
83
84 stopLoaders(segmentLoader, mediaType);
85
86 if (!activeGroup) {
87 // there is no group active
88 return;
89 }
90
91 if (!activeGroup.playlistLoader) {
92 if (previousActiveLoader) {
93 // The previous group had a playlist loader but the new active group does not
94 // this means we are switching from demuxed to muxed audio. In this case we want to
95 // do a destructive reset of the main segment loader and not restart the audio
96 // loaders.
97 mainSegmentLoader.resetEverything();
98 }
99 return;
100 }
101
102 // Non-destructive resync
103 segmentLoader.resyncLoader();
104
105 startLoaders(activeGroup.playlistLoader, mediaType);
106};
107
108/**
109 * Returns a function to be called when the media track changes. It performs a
110 * destructive reset of the SegmentLoader to ensure we start loading as close to
111 * currentTime as possible.
112 *
113 * @param {String} type
114 * MediaGroup type
115 * @param {Object} settings
116 * Object containing required information for media groups
117 * @return {Function}
118 * Handler for a destructive reset of SegmentLoader when the active media
119 * track changes.
120 * @function onTrackChanged
121 */
122export const onTrackChanged = (type, settings) => () => {
123 const {
124 segmentLoaders: {
125 [type]: segmentLoader,
126 main: mainSegmentLoader
127 },
128 mediaTypes: { [type]: mediaType }
129 } = settings;
130 const activeTrack = mediaType.activeTrack();
131 const activeGroup = mediaType.activeGroup(activeTrack);
132 const previousActiveLoader = mediaType.activePlaylistLoader;
133
134 stopLoaders(segmentLoader, mediaType);
135
136 if (!activeGroup) {
137 // there is no group active so we do not want to restart loaders
138 return;
139 }
140
141 if (!activeGroup.playlistLoader) {
142 // when switching from demuxed audio/video to muxed audio/video (noted by no playlist
143 // loader for the audio group), we want to do a destructive reset of the main segment
144 // loader and not restart the audio loaders
145 mainSegmentLoader.resetEverything();
146 return;
147 }
148
149 if (previousActiveLoader === activeGroup.playlistLoader) {
150 // Nothing has actually changed. This can happen because track change events can fire
151 // multiple times for a "single" change. One for enabling the new active track, and
152 // one for disabling the track that was active
153 startLoaders(activeGroup.playlistLoader, mediaType);
154 return;
155 }
156
157 if (segmentLoader.track) {
158 // For WebVTT, set the new text track in the segmentloader
159 segmentLoader.track(activeTrack);
160 }
161
162 // destructive reset
163 segmentLoader.resetEverything();
164
165 startLoaders(activeGroup.playlistLoader, mediaType);
166};
167
168export const onError = {
169 /**
170 * Returns a function to be called when a SegmentLoader or PlaylistLoader encounters
171 * an error.
172 *
173 * @param {String} type
174 * MediaGroup type
175 * @param {Object} settings
176 * Object containing required information for media groups
177 * @return {Function}
178 * Error handler. Logs warning (or error if the playlist is blacklisted) to
179 * console and switches back to default audio track.
180 * @function onError.AUDIO
181 */
182 AUDIO: (type, settings) => () => {
183 const {
184 segmentLoaders: { [type]: segmentLoader},
185 mediaTypes: { [type]: mediaType },
186 blacklistCurrentPlaylist
187 } = settings;
188
189 stopLoaders(segmentLoader, mediaType);
190
191 // switch back to default audio track
192 const activeTrack = mediaType.activeTrack();
193 const activeGroup = mediaType.activeGroup();
194 const id = (activeGroup.filter(group => group.default)[0] || activeGroup[0]).id;
195 const defaultTrack = mediaType.tracks[id];
196
197 if (activeTrack === defaultTrack) {
198 // Default track encountered an error. All we can do now is blacklist the current
199 // rendition and hope another will switch audio groups
200 blacklistCurrentPlaylist({
201 message: 'Problem encountered loading the default audio track.'
202 });
203 return;
204 }
205
206 videojs.log.warn('Problem encountered loading the alternate audio track.' +
207 'Switching back to default.');
208
209 for (let trackId in mediaType.tracks) {
210 mediaType.tracks[trackId].enabled = mediaType.tracks[trackId] === defaultTrack;
211 }
212
213 mediaType.onTrackChanged();
214 },
215 /**
216 * Returns a function to be called when a SegmentLoader or PlaylistLoader encounters
217 * an error.
218 *
219 * @param {String} type
220 * MediaGroup type
221 * @param {Object} settings
222 * Object containing required information for media groups
223 * @return {Function}
224 * Error handler. Logs warning to console and disables the active subtitle track
225 * @function onError.SUBTITLES
226 */
227 SUBTITLES: (type, settings) => () => {
228 const {
229 segmentLoaders: { [type]: segmentLoader},
230 mediaTypes: { [type]: mediaType }
231 } = settings;
232
233 videojs.log.warn('Problem encountered loading the subtitle track.' +
234 'Disabling subtitle track.');
235
236 stopLoaders(segmentLoader, mediaType);
237
238 const track = mediaType.activeTrack();
239
240 if (track) {
241 track.mode = 'disabled';
242 }
243
244 mediaType.onTrackChanged();
245 }
246};
247
248export const setupListeners = {
249 /**
250 * Setup event listeners for audio playlist loader
251 *
252 * @param {String} type
253 * MediaGroup type
254 * @param {PlaylistLoader|null} playlistLoader
255 * PlaylistLoader to register listeners on
256 * @param {Object} settings
257 * Object containing required information for media groups
258 * @function setupListeners.AUDIO
259 */
260 AUDIO: (type, playlistLoader, settings) => {
261 if (!playlistLoader) {
262 // no playlist loader means audio will be muxed with the video
263 return;
264 }
265
266 const {
267 tech,
268 requestOptions,
269 segmentLoaders: { [type]: segmentLoader }
270 } = settings;
271
272 playlistLoader.on('loadedmetadata', () => {
273 const media = playlistLoader.media();
274
275 segmentLoader.playlist(media, requestOptions);
276
277 // if the video is already playing, or if this isn't a live video and preload
278 // permits, start downloading segments
279 if (!tech.paused() || (media.endList && tech.preload() !== 'none')) {
280 segmentLoader.load();
281 }
282 });
283
284 playlistLoader.on('loadedplaylist', () => {
285 segmentLoader.playlist(playlistLoader.media(), requestOptions);
286
287 // If the player isn't paused, ensure that the segment loader is running
288 if (!tech.paused()) {
289 segmentLoader.load();
290 }
291 });
292
293 playlistLoader.on('error', onError[type](type, settings));
294 },
295 /**
296 * Setup event listeners for subtitle playlist loader
297 *
298 * @param {String} type
299 * MediaGroup type
300 * @param {PlaylistLoader|null} playlistLoader
301 * PlaylistLoader to register listeners on
302 * @param {Object} settings
303 * Object containing required information for media groups
304 * @function setupListeners.SUBTITLES
305 */
306 SUBTITLES: (type, playlistLoader, settings) => {
307 const {
308 tech,
309 requestOptions,
310 segmentLoaders: { [type]: segmentLoader },
311 mediaTypes: { [type]: mediaType }
312 } = settings;
313
314 playlistLoader.on('loadedmetadata', () => {
315 const media = playlistLoader.media();
316
317 segmentLoader.playlist(media, requestOptions);
318 segmentLoader.track(mediaType.activeTrack());
319
320 // if the video is already playing, or if this isn't a live video and preload
321 // permits, start downloading segments
322 if (!tech.paused() || (media.endList && tech.preload() !== 'none')) {
323 segmentLoader.load();
324 }
325 });
326
327 playlistLoader.on('loadedplaylist', () => {
328 segmentLoader.playlist(playlistLoader.media(), requestOptions);
329
330 // If the player isn't paused, ensure that the segment loader is running
331 if (!tech.paused()) {
332 segmentLoader.load();
333 }
334 });
335
336 playlistLoader.on('error', onError[type](type, settings));
337 }
338};
339
340export const initialize = {
341 /**
342 * Setup PlaylistLoaders and AudioTracks for the audio groups
343 *
344 * @param {String} type
345 * MediaGroup type
346 * @param {Object} settings
347 * Object containing required information for media groups
348 * @function initialize.AUDIO
349 */
350 'AUDIO': (type, settings) => {
351 const {
352 mode,
353 hls,
354 segmentLoaders: { [type]: segmentLoader },
355 requestOptions: { withCredentials },
356 master: { mediaGroups },
357 mediaTypes: {
358 [type]: {
359 groups,
360 tracks
361 }
362 }
363 } = settings;
364
365 // force a default if we have none or we are not
366 // in html5 mode (the only mode to support more than one
367 // audio track)
368 if (!mediaGroups[type] ||
369 Object.keys(mediaGroups[type]).length === 0 ||
370 mode !== 'html5') {
371 mediaGroups[type] = { main: { default: { default: true } } };
372 }
373
374 for (let groupId in mediaGroups[type]) {
375 if (!groups[groupId]) {
376 groups[groupId] = [];
377 }
378
379 for (let variantLabel in mediaGroups[type][groupId]) {
380 let properties = mediaGroups[type][groupId][variantLabel];
381 let playlistLoader;
382
383 if (properties.resolvedUri) {
384 playlistLoader = new PlaylistLoader(properties.resolvedUri,
385 hls,
386 withCredentials);
387 } else {
388 // no resolvedUri means the audio is muxed with the video when using this
389 // audio track
390 playlistLoader = null;
391 }
392
393 properties = videojs.mergeOptions({ id: variantLabel, playlistLoader },
394 properties);
395
396 setupListeners[type](type, properties.playlistLoader, settings);
397
398 groups[groupId].push(properties);
399
400 if (typeof tracks[variantLabel] === 'undefined') {
401 const track = new videojs.AudioTrack({
402 id: variantLabel,
403 kind: audioTrackKind_(properties),
404 enabled: false,
405 language: properties.language,
406 default: properties.default,
407 label: variantLabel
408 });
409
410 tracks[variantLabel] = track;
411 }
412 }
413 }
414
415 // setup single error event handler for the segment loader
416 segmentLoader.on('error', onError[type](type, settings));
417 },
418 /**
419 * Setup PlaylistLoaders and TextTracks for the subtitle groups
420 *
421 * @param {String} type
422 * MediaGroup type
423 * @param {Object} settings
424 * Object containing required information for media groups
425 * @function initialize.SUBTITLES
426 */
427 'SUBTITLES': (type, settings) => {
428 const {
429 tech,
430 hls,
431 segmentLoaders: { [type]: segmentLoader },
432 requestOptions: { withCredentials },
433 master: { mediaGroups },
434 mediaTypes: {
435 [type]: {
436 groups,
437 tracks
438 }
439 }
440 } = settings;
441
442 for (let groupId in mediaGroups[type]) {
443 if (!groups[groupId]) {
444 groups[groupId] = [];
445 }
446
447 for (let variantLabel in mediaGroups[type][groupId]) {
448 if (mediaGroups[type][groupId][variantLabel].forced) {
449 // Subtitle playlists with the forced attribute are not selectable in Safari.
450 // According to Apple's HLS Authoring Specification:
451 // If content has forced subtitles and regular subtitles in a given language,
452 // the regular subtitles track in that language MUST contain both the forced
453 // subtitles and the regular subtitles for that language.
454 // Because of this requirement and that Safari does not add forced subtitles,
455 // forced subtitles are skipped here to maintain consistent experience across
456 // all platforms
457 continue;
458 }
459
460 let properties = mediaGroups[type][groupId][variantLabel];
461
462 properties = videojs.mergeOptions({
463 id: variantLabel,
464 playlistLoader: new PlaylistLoader(properties.resolvedUri,
465 hls,
466 withCredentials)
467 }, properties);
468
469 setupListeners[type](type, properties.playlistLoader, settings);
470
471 groups[groupId].push(properties);
472
473 if (typeof tracks[variantLabel] === 'undefined') {
474 const track = tech.addRemoteTextTrack({
475 id: variantLabel,
476 kind: 'subtitles',
477 enabled: false,
478 language: properties.language,
479 label: variantLabel
480 }, false).track;
481
482 tracks[variantLabel] = track;
483 }
484 }
485 }
486
487 // setup single error event handler for the segment loader
488 segmentLoader.on('error', onError[type](type, settings));
489 },
490 /**
491 * Setup TextTracks for the closed-caption groups
492 *
493 * @param {String} type
494 * MediaGroup type
495 * @param {Object} settings
496 * Object containing required information for media groups
497 * @function initialize['CLOSED-CAPTIONS']
498 */
499 'CLOSED-CAPTIONS': (type, settings) => {
500 const {
501 tech,
502 master: { mediaGroups },
503 mediaTypes: {
504 [type]: {
505 groups,
506 tracks
507 }
508 }
509 } = settings;
510
511 for (let groupId in mediaGroups[type]) {
512 if (!groups[groupId]) {
513 groups[groupId] = [];
514 }
515
516 for (let variantLabel in mediaGroups[type][groupId]) {
517 let properties = mediaGroups[type][groupId][variantLabel];
518
519 // We only support CEA608 captions for now, so ignore anything that
520 // doesn't use a CCx INSTREAM-ID
521 if (!properties.instreamId.match(/CC\d/)) {
522 continue;
523 }
524
525 // No PlaylistLoader is required for Closed-Captions because the captions are
526 // embedded within the video stream
527 groups[groupId].push(videojs.mergeOptions({ id: variantLabel }, properties));
528
529 if (typeof tracks[variantLabel] === 'undefined') {
530 const track = tech.addRemoteTextTrack({
531 id: properties.instreamId,
532 kind: 'captions',
533 enabled: false,
534 language: properties.language,
535 label: variantLabel
536 }, false).track;
537
538 tracks[variantLabel] = track;
539 }
540 }
541 }
542 }
543};
544
545/**
546 * Returns a function used to get the active group of the provided type
547 *
548 * @param {String} type
549 * MediaGroup type
550 * @param {Object} settings
551 * Object containing required information for media groups
552 * @return {Function}
553 * Function that returns the active media group for the provided type. Takes an
554 * optional parameter {TextTrack} track. If no track is provided, a list of all
555 * variants in the group, otherwise the variant corresponding to the provided
556 * track is returned.
557 * @function activeGroup
558 */
559export const activeGroup = (type, settings) => (track) => {
560 const {
561 masterPlaylistLoader,
562 mediaTypes: { [type]: { groups } }
563 } = settings;
564
565 const media = masterPlaylistLoader.media();
566
567 if (!media) {
568 return null;
569 }
570
571 let variants = null;
572
573 if (media.attributes[type]) {
574 variants = groups[media.attributes[type]];
575 }
576
577 variants = variants || groups.main;
578
579 if (typeof track === 'undefined') {
580 return variants;
581 }
582
583 if (track === null) {
584 // An active track was specified so a corresponding group is expected. track === null
585 // means no track is currently active so there is no corresponding group
586 return null;
587 }
588
589 return variants.filter((props) => props.id === track.id)[0] || null;
590};
591
592export const activeTrack = {
593 /**
594 * Returns a function used to get the active track of type provided
595 *
596 * @param {String} type
597 * MediaGroup type
598 * @param {Object} settings
599 * Object containing required information for media groups
600 * @return {Function}
601 * Function that returns the active media track for the provided type. Returns
602 * null if no track is active
603 * @function activeTrack.AUDIO
604 */
605 AUDIO: (type, settings) => () => {
606 const { mediaTypes: { [type]: { tracks } } } = settings;
607
608 for (let id in tracks) {
609 if (tracks[id].enabled) {
610 return tracks[id];
611 }
612 }
613
614 return null;
615 },
616 /**
617 * Returns a function used to get the active track of type provided
618 *
619 * @param {String} type
620 * MediaGroup type
621 * @param {Object} settings
622 * Object containing required information for media groups
623 * @return {Function}
624 * Function that returns the active media track for the provided type. Returns
625 * null if no track is active
626 * @function activeTrack.SUBTITLES
627 */
628 SUBTITLES: (type, settings) => () => {
629 const { mediaTypes: { [type]: { tracks } } } = settings;
630
631 for (let id in tracks) {
632 if (tracks[id].mode === 'showing') {
633 return tracks[id];
634 }
635 }
636
637 return null;
638 }
639};
640
641/**
642 * Setup PlaylistLoaders and Tracks for media groups (Audio, Subtitles,
643 * Closed-Captions) specified in the master manifest.
644 *
645 * @param {Object} settings
646 * Object containing required information for setting up the media groups
647 * @param {SegmentLoader} settings.segmentLoaders.AUDIO
648 * Audio segment loader
649 * @param {SegmentLoader} settings.segmentLoaders.SUBTITLES
650 * Subtitle segment loader
651 * @param {SegmentLoader} settings.segmentLoaders.main
652 * Main segment loader
653 * @param {Tech} settings.tech
654 * The tech of the player
655 * @param {Object} settings.requestOptions
656 * XHR request options used by the segment loaders
657 * @param {PlaylistLoader} settings.masterPlaylistLoader
658 * PlaylistLoader for the master source
659 * @param {String} mode
660 * Mode of the hls source handler. Can be 'auto', 'html5', or 'flash'
661 * @param {HlsHandler} settings.hls
662 * HLS SourceHandler
663 * @param {Object} settings.master
664 * The parsed master manifest
665 * @param {Object} settings.mediaTypes
666 * Object to store the loaders, tracks, and utility methods for each media type
667 * @param {Function} settings.blacklistCurrentPlaylist
668 * Blacklists the current rendition and forces a rendition switch.
669 * @function setupMediaGroups
670 */
671export const setupMediaGroups = (settings) => {
672 ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((type) => {
673 initialize[type](type, settings);
674 });
675
676 const {
677 mediaTypes,
678 masterPlaylistLoader,
679 tech,
680 hls
681 } = settings;
682
683 // setup active group and track getters and change event handlers
684 ['AUDIO', 'SUBTITLES'].forEach((type) => {
685 mediaTypes[type].activeGroup = activeGroup(type, settings);
686 mediaTypes[type].activeTrack = activeTrack[type](type, settings);
687 mediaTypes[type].onGroupChanged = onGroupChanged(type, settings);
688 mediaTypes[type].onTrackChanged = onTrackChanged(type, settings);
689 });
690
691 // DO NOT enable the default subtitle or caption track.
692 // DO enable the default audio track
693 const audioGroup = mediaTypes.AUDIO.activeGroup();
694 const groupId = (audioGroup.filter(group => group.default)[0] || audioGroup[0]).id;
695
696 mediaTypes.AUDIO.tracks[groupId].enabled = true;
697 mediaTypes.AUDIO.onTrackChanged();
698
699 masterPlaylistLoader.on('mediachange', () => {
700 ['AUDIO', 'SUBTITLES'].forEach(type => mediaTypes[type].onGroupChanged());
701 });
702
703 // custom audio track change event handler for usage event
704 const onAudioTrackChanged = () => {
705 mediaTypes.AUDIO.onTrackChanged();
706 tech.trigger({ type: 'usage', name: 'hls-audio-change' });
707 };
708
709 tech.audioTracks().addEventListener('change', onAudioTrackChanged);
710 tech.remoteTextTracks().addEventListener('change',
711 mediaTypes.SUBTITLES.onTrackChanged);
712
713 hls.on('dispose', () => {
714 tech.audioTracks().removeEventListener('change', onAudioTrackChanged);
715 tech.remoteTextTracks().removeEventListener('change',
716 mediaTypes.SUBTITLES.onTrackChanged);
717 });
718
719 // clear existing audio tracks and add the ones we just created
720 tech.clearTracks('audio');
721
722 for (let id in mediaTypes.AUDIO.tracks) {
723 tech.audioTracks().addTrack(mediaTypes.AUDIO.tracks[id]);
724 }
725};
726
727/**
728 * Creates skeleton object used to store the loaders, tracks, and utility methods for each
729 * media type
730 *
731 * @return {Object}
732 * Object to store the loaders, tracks, and utility methods for each media type
733 * @function createMediaTypes
734 */
735export const createMediaTypes = () => {
736 const mediaTypes = {};
737
738 ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((type) => {
739 mediaTypes[type] = {
740 groups: {},
741 tracks: {},
742 activePlaylistLoader: null,
743 activeGroup: noop,
744 activeTrack: noop,
745 onGroupChanged: noop,
746 onTrackChanged: noop
747 };
748 });
749
750 return mediaTypes;
751};