Cue Tags (#791)
* Add text track cues for #ext-x-cue-out, #ext-x-cue-in, and #ext-x-cue-cont segment tags * Change tags track to keep its reference and add documentation
Showing
4 changed files
with
300 additions
and
1 deletions
... | @@ -24,6 +24,7 @@ Play back HLS with video.js, even where it's not natively supported. | ... | @@ -24,6 +24,7 @@ Play back HLS with video.js, even where it's not natively supported. |
24 | - [Source](#source) | 24 | - [Source](#source) |
25 | - [List](#list) | 25 | - [List](#list) |
26 | - [withCredentials](#withcredentials) | 26 | - [withCredentials](#withcredentials) |
27 | - [useCueTags](#usecuetags) | ||
27 | - [Runtime Properties](#runtime-properties) | 28 | - [Runtime Properties](#runtime-properties) |
28 | - [hls.playlists.master](#hlsplaylistsmaster) | 29 | - [hls.playlists.master](#hlsplaylistsmaster) |
29 | - [hls.playlists.media](#hlsplaylistsmedia) | 30 | - [hls.playlists.media](#hlsplaylistsmedia) |
... | @@ -212,6 +213,43 @@ is set to `true`. | ... | @@ -212,6 +213,43 @@ is set to `true`. |
212 | See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) | 213 | See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) |
213 | for more info. | 214 | for more info. |
214 | 215 | ||
216 | ##### useCueTags | ||
217 | * Type: `boolean` | ||
218 | * can be used as an initialization option | ||
219 | |||
220 | When the `useCueTags` property is set to `true,` a text track is created with | ||
221 | label 'hls-segment-metadata' and kind 'metadata'. The track is then added to | ||
222 | `player.textTracks()`. Whenever a segment associated with a cue tag is playing, | ||
223 | the cue tags will be listed as a properties inside of a stringified JSON object | ||
224 | under its active cue's `text` property. The properties that are currently | ||
225 | supported are cueOut, cueOutCont, and cueIn. Changes in active cue may be | ||
226 | tracked by following the Video.js cue points API for text tracks. For example: | ||
227 | |||
228 | ```javascript | ||
229 | let textTracks = player.textTracks(); | ||
230 | let cuesTrack; | ||
231 | |||
232 | for (let i = 0; i < textTracks.length; i++) { | ||
233 | if (textTracks[i].label === 'hls-segment-metadata') { | ||
234 | cuesTrack = textTracks[i]; | ||
235 | } | ||
236 | } | ||
237 | |||
238 | cuesTrack.addEventListener('cuechange', function() { | ||
239 | let activeCues = cuesTrack.activeCues; | ||
240 | |||
241 | for (let i = 0; i < activeCues.length; i++) { | ||
242 | let activeCue = activeCues[i]; | ||
243 | let cueData = JSON.parse(activeCue.text); | ||
244 | |||
245 | console.log('Cue runs from ' + activeCue.startTime + | ||
246 | ' to ' + activeCue.endTime + | ||
247 | ' with cue tag contents ' + | ||
248 | (cueData.cueOut || cueData.cueOutCont || cueData.cueIn)); | ||
249 | } | ||
250 | }); | ||
251 | ``` | ||
252 | |||
215 | ### Runtime Properties | 253 | ### Runtime Properties |
216 | Runtime properties are attached to the tech object when HLS is in | 254 | Runtime properties are attached to the tech object when HLS is in |
217 | use. You can get a reference to the HLS source handler like this: | 255 | use. You can get a reference to the HLS source handler like this: | ... | ... |
... | @@ -6,6 +6,7 @@ import SegmentLoader from './segment-loader'; | ... | @@ -6,6 +6,7 @@ import SegmentLoader from './segment-loader'; |
6 | import Ranges from './ranges'; | 6 | import Ranges from './ranges'; |
7 | import videojs from 'video.js'; | 7 | import videojs from 'video.js'; |
8 | import HlsAudioTrack from './hls-audio-track'; | 8 | import HlsAudioTrack from './hls-audio-track'; |
9 | import window from 'global/window'; | ||
9 | 10 | ||
10 | // 5 minute blacklist | 11 | // 5 minute blacklist |
11 | const BLACKLIST_DURATION = 5 * 60 * 1000; | 12 | const BLACKLIST_DURATION = 5 * 60 * 1000; |
... | @@ -48,7 +49,8 @@ export default class MasterPlaylistController extends videojs.EventTarget { | ... | @@ -48,7 +49,8 @@ export default class MasterPlaylistController extends videojs.EventTarget { |
48 | mode, | 49 | mode, |
49 | tech, | 50 | tech, |
50 | bandwidth, | 51 | bandwidth, |
51 | externHls | 52 | externHls, |
53 | useCueTags | ||
52 | }) { | 54 | }) { |
53 | super(); | 55 | super(); |
54 | 56 | ||
... | @@ -58,6 +60,13 @@ export default class MasterPlaylistController extends videojs.EventTarget { | ... | @@ -58,6 +60,13 @@ export default class MasterPlaylistController extends videojs.EventTarget { |
58 | this.tech_ = tech; | 60 | this.tech_ = tech; |
59 | this.hls_ = tech.hls; | 61 | this.hls_ = tech.hls; |
60 | this.mode_ = mode; | 62 | this.mode_ = mode; |
63 | this.useCueTags_ = useCueTags; | ||
64 | if (this.useCueTags_) { | ||
65 | this.cueTagsTrack_ = this.tech_.addTextTrack('metadata', 'hls-segment-metadata'); | ||
66 | this.cueTagsTrack_.inBandMetadataTrackDispatchType = ''; | ||
67 | this.tech_.textTracks().addTrack_(this.cueTagsTrack_); | ||
68 | } | ||
69 | |||
61 | this.audioTracks_ = []; | 70 | this.audioTracks_ = []; |
62 | this.requestOptions_ = { | 71 | this.requestOptions_ = { |
63 | withCredentials: this.withCredentials, | 72 | withCredentials: this.withCredentials, |
... | @@ -124,6 +133,8 @@ export default class MasterPlaylistController extends videojs.EventTarget { | ... | @@ -124,6 +133,8 @@ export default class MasterPlaylistController extends videojs.EventTarget { |
124 | return; | 133 | return; |
125 | } | 134 | } |
126 | 135 | ||
136 | this.updateCues_(updatedPlaylist); | ||
137 | |||
127 | // TODO: Create a new event on the PlaylistLoader that signals | 138 | // TODO: Create a new event on the PlaylistLoader that signals |
128 | // that the segments have changed in some way and use that to | 139 | // that the segments have changed in some way and use that to |
129 | // update the SegmentLoader instead of doing it twice here and | 140 | // update the SegmentLoader instead of doing it twice here and |
... | @@ -819,4 +830,44 @@ export default class MasterPlaylistController extends videojs.EventTarget { | ... | @@ -819,4 +830,44 @@ export default class MasterPlaylistController extends videojs.EventTarget { |
819 | } | 830 | } |
820 | }); | 831 | }); |
821 | } | 832 | } |
833 | |||
834 | updateCues_(media) { | ||
835 | if (!this.useCueTags_ || !media.segments) { | ||
836 | return; | ||
837 | } | ||
838 | |||
839 | while (this.cueTagsTrack_.cues.length) { | ||
840 | this.cueTagsTrack_.removeCue(this.cueTagsTrack_.cues[0]); | ||
841 | } | ||
842 | |||
843 | let mediaTime = 0; | ||
844 | |||
845 | for (let i = 0; i < media.segments.length; i++) { | ||
846 | let segment = media.segments[i]; | ||
847 | |||
848 | if ('cueOut' in segment || 'cueOutCont' in segment || 'cueIn' in segment) { | ||
849 | let cueJson = {}; | ||
850 | |||
851 | if ('cueOut' in segment) { | ||
852 | cueJson.cueOut = segment.cueOut; | ||
853 | } | ||
854 | if ('cueOutCont' in segment) { | ||
855 | cueJson.cueOutCont = segment.cueOutCont; | ||
856 | } | ||
857 | if ('cueIn' in segment) { | ||
858 | cueJson.cueIn = segment.cueIn; | ||
859 | } | ||
860 | |||
861 | // Use a short duration for the cue point, as it should trigger for a segment | ||
862 | // transition (in this case, defined as the beginning of the segment that the tag | ||
863 | // precedes), but keep it for a minimum of 0.5 seconds to remain usable (won't | ||
864 | // lose it as an active cue by the time a user retrieves the active cues). | ||
865 | this.cueTagsTrack_.addCue(new window.VTTCue(mediaTime, | ||
866 | mediaTime + 0.5, | ||
867 | JSON.stringify(cueJson))); | ||
868 | } | ||
869 | |||
870 | mediaTime += segment.duration; | ||
871 | } | ||
872 | } | ||
822 | } | 873 | } | ... | ... |
... | @@ -13,6 +13,7 @@ import MasterPlaylistController from '../src/master-playlist-controller'; | ... | @@ -13,6 +13,7 @@ import MasterPlaylistController from '../src/master-playlist-controller'; |
13 | import { Hls } from '../src/videojs-contrib-hls'; | 13 | import { Hls } from '../src/videojs-contrib-hls'; |
14 | /* eslint-enable no-unused-vars */ | 14 | /* eslint-enable no-unused-vars */ |
15 | import Playlist from '../src/playlist'; | 15 | import Playlist from '../src/playlist'; |
16 | import window from 'global/window'; | ||
16 | 17 | ||
17 | QUnit.module('MasterPlaylistController', { | 18 | QUnit.module('MasterPlaylistController', { |
18 | beforeEach() { | 19 | beforeEach() { |
... | @@ -617,3 +618,184 @@ function() { | ... | @@ -617,3 +618,184 @@ function() { |
617 | 618 | ||
618 | Playlist.seekable = origSeekable; | 619 | Playlist.seekable = origSeekable; |
619 | }); | 620 | }); |
621 | |||
622 | QUnit.test('calls to update cues on new media', function() { | ||
623 | let callCount = 0; | ||
624 | |||
625 | this.masterPlaylistController.updateCues_ = (media) => callCount++; | ||
626 | |||
627 | // master | ||
628 | standardXHRResponse(this.requests.shift()); | ||
629 | |||
630 | QUnit.equal(callCount, 0, 'no call to update cues on master'); | ||
631 | |||
632 | // media | ||
633 | standardXHRResponse(this.requests.shift()); | ||
634 | |||
635 | QUnit.equal(callCount, 1, 'calls to update cues on first media'); | ||
636 | |||
637 | this.masterPlaylistController.masterPlaylistLoader_.trigger('loadedplaylist'); | ||
638 | |||
639 | QUnit.equal(callCount, 2, 'calls to update cues on subsequent media'); | ||
640 | }); | ||
641 | |||
642 | QUnit.test('calls to update cues on media when no master', function() { | ||
643 | this.requests.length = 0; | ||
644 | this.player.src({ | ||
645 | src: 'manifest/media.m3u8', | ||
646 | type: 'application/vnd.apple.mpegurl' | ||
647 | }); | ||
648 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
649 | |||
650 | let callCount = 0; | ||
651 | |||
652 | this.masterPlaylistController.updateCues_ = (media) => callCount++; | ||
653 | |||
654 | // media | ||
655 | standardXHRResponse(this.requests.shift()); | ||
656 | |||
657 | QUnit.equal(callCount, 1, 'calls to update cues on first media'); | ||
658 | |||
659 | this.masterPlaylistController.masterPlaylistLoader_.trigger('loadedplaylist'); | ||
660 | |||
661 | QUnit.equal(callCount, 2, 'calls to update cues on subsequent media'); | ||
662 | }); | ||
663 | |||
664 | QUnit.test('respects useCueTags option', function() { | ||
665 | this.masterPlaylistController.updateCues_({ | ||
666 | segments: [{ | ||
667 | duration: 10, | ||
668 | tags: ['test'] | ||
669 | }] | ||
670 | }); | ||
671 | |||
672 | QUnit.ok(!this.masterPlaylistController.cueTagsTrack_, | ||
673 | 'does not create cueTagsTrack_ if useCueTags is falsy'); | ||
674 | QUnit.equal(this.player.textTracks().length, | ||
675 | 0, | ||
676 | 'does not create a text track if useCueTags is falsy'); | ||
677 | |||
678 | this.player.dispose(); | ||
679 | |||
680 | let origHlsOptions = videojs.options.hls; | ||
681 | |||
682 | videojs.options.hls = { | ||
683 | useCueTags: true | ||
684 | }; | ||
685 | |||
686 | this.player = createPlayer(); | ||
687 | this.player.src({ | ||
688 | src: 'manifest/media.m3u8', | ||
689 | type: 'application/vnd.apple.mpegurl' | ||
690 | }); | ||
691 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
692 | |||
693 | QUnit.ok(this.masterPlaylistController.cueTagsTrack_, | ||
694 | 'creates cueTagsTrack_ if useCueTags is truthy'); | ||
695 | QUnit.equal(this.masterPlaylistController.cueTagsTrack_.label, | ||
696 | 'hls-segment-metadata', | ||
697 | 'cueTagsTrack_ has label of hls-segment-metadata'); | ||
698 | QUnit.equal(this.player.textTracks()[0], this.masterPlaylistController.cueTagsTrack_, | ||
699 | 'adds cueTagsTrack as a text track if useCueTags is truthy'); | ||
700 | |||
701 | this.masterPlaylistController.updateCues_({ | ||
702 | segments: [{ | ||
703 | duration: 10, | ||
704 | cueOut: 'test' | ||
705 | }] | ||
706 | }); | ||
707 | |||
708 | let cue = this.masterPlaylistController.cueTagsTrack_.cues[0]; | ||
709 | |||
710 | QUnit.equal(cue.startTime, | ||
711 | 0, | ||
712 | 'adds cue with correct start time if useCueTags is truthy'); | ||
713 | QUnit.equal(cue.endTime, | ||
714 | 0.5, | ||
715 | 'adds cue with correct end time if useCueTags is truthy'); | ||
716 | QUnit.equal(cue.text, | ||
717 | JSON.stringify({ cueOut: 'test' }), | ||
718 | 'adds cue with correct text if useCueTags is truthy'); | ||
719 | |||
720 | videojs.options.hls = origHlsOptions; | ||
721 | }); | ||
722 | |||
723 | QUnit.test('update tag cues', function() { | ||
724 | let origHlsOptions = videojs.options.hls; | ||
725 | |||
726 | videojs.options.hls = { | ||
727 | useCueTags: true | ||
728 | }; | ||
729 | |||
730 | this.player = createPlayer(); | ||
731 | this.player.src({ | ||
732 | src: 'manifest/master.m3u8', | ||
733 | type: 'application/vnd.apple.mpegurl' | ||
734 | }); | ||
735 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
736 | |||
737 | let cueTagsTrack = this.masterPlaylistController.cueTagsTrack_; | ||
738 | let testCue = new window.VTTCue(0, 10, 'test'); | ||
739 | |||
740 | cueTagsTrack.addCue(testCue); | ||
741 | |||
742 | this.masterPlaylistController.updateCues_({}); | ||
743 | |||
744 | QUnit.equal(cueTagsTrack.cues.length, | ||
745 | 1, | ||
746 | 'does not change cues if media does not have segment property'); | ||
747 | QUnit.equal(cueTagsTrack.cues[0], | ||
748 | testCue, | ||
749 | 'does not change cues if media does not have segment property'); | ||
750 | |||
751 | this.masterPlaylistController.updateCues_({ | ||
752 | segments: [] | ||
753 | }); | ||
754 | |||
755 | QUnit.equal(cueTagsTrack.cues.length, | ||
756 | 0, | ||
757 | 'removes cues even if no segments in playlist'); | ||
758 | |||
759 | this.masterPlaylistController.updateCues_({ | ||
760 | segments: [{ | ||
761 | duration: 5.1, | ||
762 | cueOut: '11.5' | ||
763 | }, { | ||
764 | duration: 6.4, | ||
765 | cueOutCont: '5.1/11.5' | ||
766 | }, { | ||
767 | duration: 6, | ||
768 | cueIn: '' | ||
769 | }] | ||
770 | }); | ||
771 | |||
772 | QUnit.equal(cueTagsTrack.cues.length, 3, 'adds a cue for each segment'); | ||
773 | |||
774 | QUnit.equal(cueTagsTrack.cues[0].startTime, 0, 'cue starts at 0'); | ||
775 | QUnit.equal(cueTagsTrack.cues[0].endTime, 0.5, 'cue ends at start time plus duration'); | ||
776 | QUnit.equal(JSON.parse(cueTagsTrack.cues[0].text).cueOut, '11.5', 'cueOut matches'); | ||
777 | QUnit.ok(!('cueOutCont' in JSON.parse(cueTagsTrack.cues[0].text)), | ||
778 | 'cueOutCont not in cue'); | ||
779 | QUnit.ok(!('cueIn' in JSON.parse(cueTagsTrack.cues[0].text)), 'cueIn not in cue'); | ||
780 | QUnit.equal(cueTagsTrack.cues[1].startTime, 5.1, 'cue starts at 5.1'); | ||
781 | QUnit.equal(cueTagsTrack.cues[1].endTime, 5.6, 'cue ends at start time plus duration'); | ||
782 | QUnit.equal(JSON.parse(cueTagsTrack.cues[1].text).cueOutCont, | ||
783 | '5.1/11.5', | ||
784 | 'cueOutCont matches'); | ||
785 | QUnit.ok(!('cueOut' in JSON.parse(cueTagsTrack.cues[1].text)), 'cueOut not in cue'); | ||
786 | QUnit.ok(!('cueIn' in JSON.parse(cueTagsTrack.cues[1].text)), 'cueIn not in cue'); | ||
787 | QUnit.equal(cueTagsTrack.cues[2].startTime, 11.5, 'cue starts at 11.5'); | ||
788 | QUnit.equal(cueTagsTrack.cues[2].endTime, 12, 'cue ends at start time plus duration'); | ||
789 | QUnit.equal(JSON.parse(cueTagsTrack.cues[2].text).cueIn, '', 'cueIn matches'); | ||
790 | QUnit.ok(!('cueOut' in JSON.parse(cueTagsTrack.cues[2].text)), 'cueOut not in cue'); | ||
791 | QUnit.ok(!('cueOutCont' in JSON.parse(cueTagsTrack.cues[2].text)), | ||
792 | 'cueOutCont not in cue'); | ||
793 | |||
794 | this.masterPlaylistController.updateCues_({ | ||
795 | segments: [] | ||
796 | }); | ||
797 | |||
798 | QUnit.equal(cueTagsTrack.cues.length, 0, 'removes old cues on update'); | ||
799 | |||
800 | videojs.options.hls = origHlsOptions; | ||
801 | }); | ... | ... |
... | @@ -2275,6 +2275,34 @@ QUnit.test('Allows overriding the global beforeRequest function', function() { | ... | @@ -2275,6 +2275,34 @@ QUnit.test('Allows overriding the global beforeRequest function', function() { |
2275 | QUnit.equal(this.player.tech_.hls.stats.mediaRequests, 1, 'one segment request'); | 2275 | QUnit.equal(this.player.tech_.hls.stats.mediaRequests, 1, 'one segment request'); |
2276 | }); | 2276 | }); |
2277 | 2277 | ||
2278 | QUnit.test('passes useCueTags hls option to master playlist controller', function() { | ||
2279 | this.player.src({ | ||
2280 | src: 'master.m3u8', | ||
2281 | type: 'application/vnd.apple.mpegurl' | ||
2282 | }); | ||
2283 | |||
2284 | QUnit.ok(!this.player.tech_.hls.masterPlaylistController_.useCueTags_, | ||
2285 | 'useCueTags is falsy by default'); | ||
2286 | |||
2287 | let origHlsOptions = videojs.options.hls; | ||
2288 | |||
2289 | videojs.options.hls = { | ||
2290 | useCueTags: true | ||
2291 | }; | ||
2292 | |||
2293 | this.player.dispose(); | ||
2294 | this.player = createPlayer(); | ||
2295 | this.player.src({ | ||
2296 | src: 'http://example.com/media.m3u8', | ||
2297 | type: 'application/vnd.apple.mpegurl' | ||
2298 | }); | ||
2299 | |||
2300 | QUnit.ok(this.player.tech_.hls.masterPlaylistController_.useCueTags_, | ||
2301 | 'useCueTags passed to master playlist controller'); | ||
2302 | |||
2303 | videojs.options.hls = origHlsOptions; | ||
2304 | }); | ||
2305 | |||
2278 | QUnit.module('HLS Integration', { | 2306 | QUnit.module('HLS Integration', { |
2279 | beforeEach() { | 2307 | beforeEach() { |
2280 | this.env = useFakeEnvironment(); | 2308 | this.env = useFakeEnvironment(); | ... | ... |
-
Please register or sign in to post a comment