diff --git a/src/js/control-bar/text-track-controls/chapters-button.js b/src/js/control-bar/text-track-controls/chapters-button.js index 46fe2011a5..4ec5ecd39f 100644 --- a/src/js/control-bar/text-track-controls/chapters-button.js +++ b/src/js/control-bar/text-track-controls/chapters-button.js @@ -77,21 +77,13 @@ class ChaptersButton extends TextTrackButton { let chaptersTrack; let items = this.items = []; - for (let i = 0, l = tracks.length; i < l; i++) { + for (let i = 0, length = tracks.length; i < length; i++) { let track = tracks[i]; + if (track['kind'] === this.kind_) { - if (!track.cues) { - track['mode'] = 'hidden'; - /* jshint loopfunc:true */ - // TODO see if we can figure out a better way of doing this https://github.com/videojs/video.js/issues/1864 - window.setTimeout(Fn.bind(this, function() { - this.createMenu(); - }), 100); - /* jshint loopfunc:false */ - } else { - chaptersTrack = track; - break; - } + chaptersTrack = track; + + break; } } @@ -105,7 +97,17 @@ class ChaptersButton extends TextTrackButton { })); } - if (chaptersTrack) { + if (chaptersTrack && chaptersTrack.cues == null) { + chaptersTrack['mode'] = 'hidden'; + + let remoteTextTrackEl = this.player_.remoteTextTrackEls().getTrackElementByTrack_(chaptersTrack); + + if (remoteTextTrackEl) { + remoteTextTrackEl.addEventListener('load', (event) => this.update()); + } + } + + if (chaptersTrack && chaptersTrack.cues && chaptersTrack.cues.length > 0) { let cues = chaptersTrack['cues'], cue; for (let i = 0, l = cues.length; i < l; i++) { @@ -120,6 +122,7 @@ class ChaptersButton extends TextTrackButton { menu.addChild(mi); } + this.addChild(menu); } diff --git a/src/js/player.js b/src/js/player.js index 18d245d92b..040c4894f1 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -2492,6 +2492,16 @@ class Player extends Component { return this.tech_ && this.tech_['remoteTextTracks'](); } + /** + * Get an array of remote html track elements + * + * @return {HTMLTrackElement[]} + * @method remoteTextTrackEls + */ + remoteTextTrackEls() { + return this.tech_ && this.tech_['remoteTextTrackEls'](); + } + /** * Add a text track * In addition to the W3C settings we allow adding additional info through options. diff --git a/src/js/tech/html5.js b/src/js/tech/html5.js index a4bbd1cded..355a68359f 100644 --- a/src/js/tech/html5.js +++ b/src/js/tech/html5.js @@ -49,6 +49,7 @@ class Html5 extends Tech { while (nodesLength--) { let node = nodes[nodesLength]; let nodeName = node.nodeName.toLowerCase(); + if (nodeName === 'track') { if (!this.featuresNativeTextTracks) { // Empty video tag tracks so the built-in player doesn't use them also. @@ -57,6 +58,8 @@ class Html5 extends Tech { // captions and subtitles. videoElement.textTracks removeNodes.push(node); } else { + // store HTMLTrackElement and TextTrack to remote list + this.remoteTextTrackEls().addTrackElement_(node); this.remoteTextTracks().addTrack_(node.track); } } @@ -731,11 +734,11 @@ class Html5 extends Tech { } /** - * Creates and returns a remote text track object + * Creates a remote text track object and returns a html track element * * @param {Object} options The object should contain values for * kind, language, label and src (location of the WebVTT file) - * @return {TextTrackObject} + * @return {HTMLTrackElement} * @method addRemoteTextTrack */ addRemoteTextTrack(options={}) { @@ -743,32 +746,34 @@ class Html5 extends Tech { return super.addRemoteTextTrack(options); } - var track = document.createElement('track'); + let htmlTrackElement = document.createElement('track'); - if (options['kind']) { - track['kind'] = options['kind']; + if (options.kind) { + htmlTrackElement.kind = options.kind; } - if (options['label']) { - track['label'] = options['label']; + if (options.label) { + htmlTrackElement.label = options.label; } - if (options['language'] || options['srclang']) { - track['srclang'] = options['language'] || options['srclang']; + if (options.language || options.srclang) { + htmlTrackElement.srclang = options.language || options.srclang; } - if (options['default']) { - track['default'] = options['default']; + if (options.default) { + htmlTrackElement.default = options.default; } - if (options['id']) { - track['id'] = options['id']; + if (options.id) { + htmlTrackElement.id = options.id; } - if (options['src']) { - track['src'] = options['src']; + if (options.src) { + htmlTrackElement.src = options.src; } - this.el().appendChild(track); + this.el().appendChild(htmlTrackElement); - this.remoteTextTracks().addTrack_(track.track); + // store HTMLTrackElement and TextTrack to remote list + this.remoteTextTrackEls().addTrackElement_(htmlTrackElement); + this.remoteTextTracks().addTrack_(htmlTrackElement.track); - return track; + return htmlTrackElement; } /** @@ -782,8 +787,12 @@ class Html5 extends Tech { return super.removeRemoteTextTrack(track); } - var tracks, i; + let tracks, i; + + let trackElement = this.remoteTextTrackEls().getTrackElementByTrack_(track); + // remove HTMLTrackElement and TextTrack from remote list + this.remoteTextTrackEls().removeTrackElement_(trackElement); this.remoteTextTracks().removeTrack_(track); tracks = this.$$('track'); diff --git a/src/js/tech/tech.js b/src/js/tech/tech.js index c69fc0ad5e..9897e302ff 100644 --- a/src/js/tech/tech.js +++ b/src/js/tech/tech.js @@ -5,6 +5,9 @@ */ import Component from '../component'; +import HTMLTrackElement from '../tracks/html-track-element'; +import HTMLTrackElementList from '../tracks/html-track-element-list'; +import mergeOptions from '../utils/merge-options.js'; import TextTrack from '../tracks/text-track'; import TextTrackList from '../tracks/text-track-list'; import * as Fn from '../utils/fn.js'; @@ -377,6 +380,17 @@ class Tech extends Component { return this.remoteTextTracks_; } + /** + * Get remote htmltrackelements + * + * @returns {HTMLTrackElementList} + * @method remoteTextTrackEls + */ + remoteTextTrackEls() { + this.remoteTextTrackEls_ = this.remoteTextTrackEls_ || new HTMLTrackElementList(); + return this.remoteTextTrackEls_; + } + /** * Creates and returns a remote text track object * @@ -396,19 +410,28 @@ class Tech extends Component { } /** - * Creates and returns a remote text track object + * Creates a remote text track object and returns a emulated html track element * * @param {Object} options The object should contain values for * kind, language, label and src (location of the WebVTT file) - * @return {TextTrackObject} + * @return {HTMLTrackElement} * @method addRemoteTextTrack */ addRemoteTextTrack(options) { - let track = createTrackHelper(this, options.kind, options.label, options.language, options); - this.remoteTextTracks().addTrack_(track); - return { - track: track - }; + let track = mergeOptions(options, { + tech: this + }); + + let htmlTrackElement = new HTMLTrackElement(track); + + // store HTMLTrackElement and TextTrack to remote list + this.remoteTextTrackEls().addTrackElement_(htmlTrackElement); + this.remoteTextTracks().addTrack_(htmlTrackElement.track); + + // must come after remoteTextTracks() + this.textTracks().addTrack_(htmlTrackElement.track); + + return htmlTrackElement; } /** @@ -419,6 +442,11 @@ class Tech extends Component { */ removeRemoteTextTrack(track) { this.textTracks().removeTrack_(track); + + let trackElement = this.remoteTextTrackEls().getTrackElementByTrack_(track); + + // remove HTMLTrackElement and TextTrack from remote list + this.remoteTextTrackEls().removeTrackElement_(trackElement); this.remoteTextTracks().removeTrack_(track); } @@ -447,7 +475,7 @@ class Tech extends Component { /* * Return whether the argument is a Tech or not. * Can be passed either a Class like `Html5` or a instance like `player.tech_` - * + * * @param {Object} component An item to check * @return {Boolean} Whether it is a tech or not */ diff --git a/src/js/tracks/html-track-element-list.js b/src/js/tracks/html-track-element-list.js new file mode 100644 index 0000000000..5a66d27e97 --- /dev/null +++ b/src/js/tracks/html-track-element-list.js @@ -0,0 +1,68 @@ +/** + * @file html-track-element.js + */ + +import * as browser from '../utils/browser.js'; +import document from 'global/document'; + +class HtmlTrackElementList { + constructor(trackElements = []) { + let list = this; + + if (browser.IS_IE8) { + list = document.createElement('custom'); + + for (let prop in HtmlTrackElementList.prototype) { + if (prop !== 'constructor') { + list[prop] = HtmlTrackElementList.prototype[prop]; + } + } + } + + list.trackElements_ = []; + + Object.defineProperty(list, 'length', { + get() { + return this.trackElements_.length; + } + }); + + for (let i = 0, length = trackElements.length; i < length; i++) { + list.addTrackElement_(trackElements[i]); + } + + if (browser.IS_IE8) { + return list; + } + } + + addTrackElement_(trackElement) { + this.trackElements_.push(trackElement); + } + + getTrackElementByTrack_(track) { + let trackElement_; + + for (let i = 0, length = this.trackElements_.length; i < length; i++) { + if (track === this.trackElements_[i].track) { + trackElement_ = this.trackElements_[i]; + + break; + } + } + + return trackElement_; + } + + removeTrackElement_(trackElement) { + for (let i = 0, length = this.trackElements_.length; i < length; i++) { + if (trackElement === this.trackElements_[i]) { + this.trackElements_.splice(i, 1); + + break; + } + } + } +} + +export default HtmlTrackElementList; diff --git a/src/js/tracks/html-track-element.js b/src/js/tracks/html-track-element.js new file mode 100644 index 0000000000..2e6320e991 --- /dev/null +++ b/src/js/tracks/html-track-element.js @@ -0,0 +1,97 @@ +import * as browser from '../utils/browser.js'; +import document from 'global/document'; +import EventTarget from '../event-target'; +import TextTrack from '../tracks/text-track'; + +const NONE = 0; +const LOADING = 1; +const LOADED = 2; +const ERROR = 3; + +/** + * https://html.spec.whatwg.org/multipage/embedded-content.html#htmltrackelement + * + * interface HTMLTrackElement : HTMLElement { + * attribute DOMString kind; + * attribute DOMString src; + * attribute DOMString srclang; + * attribute DOMString label; + * attribute boolean default; + * + * const unsigned short NONE = 0; + * const unsigned short LOADING = 1; + * const unsigned short LOADED = 2; + * const unsigned short ERROR = 3; + * readonly attribute unsigned short readyState; + * + * readonly attribute TextTrack track; + * }; + * + * @param {Object} options TextTrack configuration + * @class HTMLTrackElement + */ + +class HTMLTrackElement extends EventTarget { + constructor(options = {}) { + super(); + + let readyState, + trackElement = this; + + if (browser.IS_IE8) { + trackElement = document.createElement('custom'); + + for (let prop in HTMLTrackElement.prototype) { + if (prop !== 'constructor') { + trackElement[prop] = HTMLTrackElement.prototype[prop]; + } + } + } + + let track = new TextTrack(options); + + trackElement.kind = track.kind; + trackElement.src = track.src; + trackElement.srclang = track.language; + trackElement.label = track.label; + trackElement.default = track.default; + + Object.defineProperty(trackElement, 'readyState', { + get() { + return readyState; + } + }); + + Object.defineProperty(trackElement, 'track', { + get() { + return track; + } + }); + + readyState = NONE; + + track.addEventListener('loadeddata', function() { + readyState = LOADED; + + trackElement.trigger({ + type: 'load', + target: trackElement + }); + }); + + if (browser.IS_IE8) { + return trackElement; + } + } +} + +HTMLTrackElement.prototype.allowedEvents_ = { + load: 'load' +}; + +HTMLTrackElement.NONE = NONE; +HTMLTrackElement.LOADING = LOADING; +HTMLTrackElement.LOADED = LOADED; +HTMLTrackElement.ERROR = ERROR; + +export default HTMLTrackElement; diff --git a/src/js/tracks/text-track-list.js b/src/js/tracks/text-track-list.js index a4f41a64d1..6022213643 100644 --- a/src/js/tracks/text-track-list.js +++ b/src/js/tracks/text-track-list.js @@ -69,6 +69,13 @@ for (let event in TextTrackList.prototype.allowedEvents_) { TextTrackList.prototype['on' + event] = null; } +/** + * Add TextTrack from TextTrackList + * + * @param {TextTrack} track + * @method addTrack_ + * @private + */ TextTrackList.prototype.addTrack_ = function(track) { let index = this.tracks_.length; if (!(''+index in this)) { @@ -90,18 +97,31 @@ TextTrackList.prototype.addTrack_ = function(track) { }); }; +/** + * Remove TextTrack from TextTrackList + * NOTE: Be mindful of what is passed in as it may be a HTMLTrackElement + * + * @param {TextTrack} rtrack + * @method removeTrack_ + * @private + */ TextTrackList.prototype.removeTrack_ = function(rtrack) { - let result = null; let track; for (let i = 0, l = this.length; i < l; i++) { - track = this[i]; - if (track === rtrack) { + if (this[i] === rtrack) { + track = this[i]; + this.tracks_.splice(i, 1); + break; } } + if (!track) { + return; + } + this.trigger({ type: 'removetrack', track: track diff --git a/src/js/tracks/text-track.js b/src/js/tracks/text-track.js index dc53812d68..5002a191df 100644 --- a/src/js/tracks/text-track.js +++ b/src/js/tracks/text-track.js @@ -45,7 +45,9 @@ let TextTrack = function(options={}) { tt = document.createElement('custom'); for (let prop in TextTrack.prototype) { - tt[prop] = TextTrack.prototype[prop]; + if (prop !== 'constructor') { + tt[prop] = TextTrack.prototype[prop]; + } } } @@ -233,24 +235,25 @@ TextTrack.prototype.removeCue = function(removeCue) { * Downloading stuff happens below this point */ var parseCues = function(srcContent, track) { - if (typeof window['WebVTT'] !== 'function') { - //try again a bit later - return window.setTimeout(function() { - parseCues(srcContent, track); - }, 25); - } + let parser = new window.WebVTT.Parser(window, window.vttjs, window.WebVTT.StringDecoder()); - let parser = new window['WebVTT']['Parser'](window, window['vttjs'], window['WebVTT']['StringDecoder']()); - - parser['oncue'] = function(cue) { + parser.oncue = function(cue) { track.addCue(cue); }; - parser['onparsingerror'] = function(error) { + + parser.onparsingerror = function(error) { log.error(error); }; - parser['parse'](srcContent); - parser['flush'](); + parser.onflush = function() { + track.trigger({ + type: 'loadeddata', + target: track + }); + }; + + parser.parse(srcContent); + parser.flush(); }; var loadTrack = function(src, track) { @@ -269,7 +272,15 @@ var loadTrack = function(src, track) { } track.loaded_ = true; - parseCues(responseBody, track); + + // NOTE: this is only used for the alt/video.novtt.js build + if (typeof window.WebVTT !== 'function') { + window.setTimeout(function() { + parseCues(responseBody, track); + }, 100); + } else { + parseCues(responseBody, track); + } })); }; diff --git a/test/api/api.js b/test/api/api.js index db818afc63..b0a12f1912 100644 --- a/test/api/api.js +++ b/test/api/api.js @@ -61,6 +61,7 @@ test('should be able to access expected player API methods', function() { // TextTrack methods ok(player.textTracks, 'textTracks exists'); + ok(player.remoteTextTrackEls, 'remoteTextTrackEls exists'); ok(player.remoteTextTracks, 'remoteTextTracks exists'); ok(player.addTextTrack, 'addTextTrack exists'); ok(player.addRemoteTextTrack, 'addRemoteTextTrack exists'); diff --git a/test/unit/tracks/html-track-element-list.test.js b/test/unit/tracks/html-track-element-list.test.js new file mode 100644 index 0000000000..38d52040db --- /dev/null +++ b/test/unit/tracks/html-track-element-list.test.js @@ -0,0 +1,59 @@ +import HTMLTrackElement from '../../../src/js/tracks/html-track-element.js'; +import HTMLTrackElementList from '../../../src/js/tracks/html-track-element-list.js'; +import TextTrack from '../../../src/js/tracks/text-track.js'; + +let noop = Function.prototype; +let defaultTech = { + textTracks: noop, + on: noop, + off: noop, + currentTime: noop +}; + +let track1 = new TextTrack({ + id: 1, + tech: defaultTech +}); +let track2 = new TextTrack({ + id: 2, + tech: defaultTech +}); + +var genericHtmlTrackElements = [{ + kind: 'captions', + tech: noop, + track: track1 +}, { + kind: 'chapters', + tech: noop, + track: track2 +}]; + +q.module('HTML Track Element List'); + +test('HTMLTrackElementList\'s length is set correctly', function() { + let htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + equal(htmlTrackElementList.length, genericHtmlTrackElements.length, `the length is ${genericHtmlTrackElements.length}`); +}); + +test('can get html track element by track', function() { + let htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + equal(htmlTrackElementList.getTrackElementByTrack_(track1).kind, 'captions', 'track1 has kind of captions'); + equal(htmlTrackElementList.getTrackElementByTrack_(track2).kind, 'chapters', 'track2 has kind of captions'); +}); + +test('length is updated when new tracks are added or removed', function() { + let htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + htmlTrackElementList.addTrackElement_({tech: noop}); + equal(htmlTrackElementList.length, genericHtmlTrackElements.length + 1, `the length is ${genericHtmlTrackElements.length + 1}`); + htmlTrackElementList.addTrackElement_({tech: noop}); + equal(htmlTrackElementList.length, genericHtmlTrackElements.length + 2, `the length is ${genericHtmlTrackElements.length + 2}`); + + htmlTrackElementList.removeTrackElement_(htmlTrackElementList.getTrackElementByTrack_(track1)); + equal(htmlTrackElementList.length, genericHtmlTrackElements.length + 1, `the length is ${genericHtmlTrackElements.length + 1}`); + htmlTrackElementList.removeTrackElement_(htmlTrackElementList.getTrackElementByTrack_(track2)); + equal(htmlTrackElementList.length, genericHtmlTrackElements.length, `the length is ${genericHtmlTrackElements.length}`); +}); diff --git a/test/unit/tracks/html-track-element.test.js b/test/unit/tracks/html-track-element.test.js new file mode 100644 index 0000000000..d00dc0fec6 --- /dev/null +++ b/test/unit/tracks/html-track-element.test.js @@ -0,0 +1,81 @@ +import HTMLTrackElement from '../../../src/js/tracks/html-track-element.js'; +import TextTrack from '../../../src/js/tracks/text-track.js'; +import window from 'global/window'; + +let noop = Function.prototype; +let defaultTech = { + textTracks: noop, + on: noop, + off: noop, + currentTime: noop +}; + +q.module('HTML Track Element'); + +test('html track element requires a tech', function() { + window.throws( + function() { + new HTMLTrackElement(); + }, + new Error('A tech was not provided.'), + 'a tech is required for html track element' + ); +}); + +test('can create a html track element with various properties', function() { + let kind = 'chapters', + label = 'English', + language = 'en', + src = 'http://www.example.com'; + + let htmlTrackElement = new HTMLTrackElement({ + kind, + label, + language, + src, + tech: defaultTech + }); + + equal(htmlTrackElement.default, undefined, 'we have a default'); + equal(htmlTrackElement.kind, kind, 'we have a kind'); + equal(htmlTrackElement.label, label, 'we have a label'); + equal(htmlTrackElement.readyState, 0, 'we have a readyState'); + equal(htmlTrackElement.src, src, 'we have a src'); + equal(htmlTrackElement.srclang, language, 'we have a srclang'); + equal(htmlTrackElement.track.cues, null, 'we have a track'); +}); + +test('defaults when items not provided', function() { + let htmlTrackElement = new HTMLTrackElement({ + tech: defaultTech + }); + + equal(htmlTrackElement.default, undefined, 'we have a default'); + equal(htmlTrackElement.kind, 'subtitles', 'we have a kind'); + equal(htmlTrackElement.label, '', 'we have a label'); + equal(htmlTrackElement.readyState, 0, 'we have a readyState'); + equal(htmlTrackElement.src, undefined, 'we have a src'); + equal(htmlTrackElement.srclang, '', 'we have a srclang'); + equal(htmlTrackElement.track.cues.length, 0, 'we have a track'); +}); + +test('fires loadeddata when track cues become populated', function() { + let changes = 0, + loadHandler; + + loadHandler = function() { + changes++; + }; + + let htmlTrackElement = new HTMLTrackElement({ + tech: noop + }); + + htmlTrackElement.addEventListener('load', loadHandler); + + // trigger loaded cues event + htmlTrackElement.track.trigger('loadeddata'); + + equal(changes, 1, 'a loadeddata event trigger addEventListener'); + equal(htmlTrackElement.readyState, 2, 'readyState is loaded'); +}); diff --git a/test/unit/tracks/text-track-list.test.js b/test/unit/tracks/text-track-list.test.js index 28ac68fd48..8306d3daee 100644 --- a/test/unit/tracks/text-track-list.test.js +++ b/test/unit/tracks/text-track-list.test.js @@ -166,7 +166,7 @@ test('trigger "change" event when "modechange" is fired on a track', function() equal(changes, 2, 'two change events should have fired'); }); -test('trigger "change" event when mode changes on a TextTracl', function() { +test('trigger "change" event when mode changes on a TextTrack', function() { var tt = new TextTrack({ tech: { on: noop diff --git a/test/unit/tracks/tracks.test.js b/test/unit/tracks/tracks.test.js index d6d9e7da36..f64cb33234 100644 --- a/test/unit/tracks/tracks.test.js +++ b/test/unit/tracks/tracks.test.js @@ -15,7 +15,14 @@ import document from 'global/document'; import window from 'global/window'; import TechFaker from '../tech/tech-faker.js'; -q.module('Tracks'); +q.module('Tracks', { + 'setup': function() { + this.clock = sinon.useFakeTimers(); + }, + 'teardown': function() { + this.clock.restore(); + } +}); test('should place title list item into ul', function() { var player, chaptersButton; @@ -398,3 +405,66 @@ test('removes cuechange event when text track is hidden for emulated tracks', fu equal(numTextTrackChanges, 4, 'texttrackchange should be not be called since mode is hidden'); }); + +test('should return correct remote text track values', function () { + let fixture = document.getElementById('qunit-fixture'); + + let html = ''; + + fixture.innerHTML += html; + + let tag = document.getElementById('example_1'); + + let player = TestHelpers.makePlayer({}, tag); + + this.clock.tick(1); + + equal(player.remoteTextTracks().length, 1, 'add text track via html'); + equal(player.remoteTextTrackEls().length, 1, 'add html track element via html'); + + let htmlTrackElement = player.addRemoteTextTrack({ + kind: 'captions', + label: 'label' + }); + + equal(player.remoteTextTracks().length, 2, 'add text track via method'); + equal(player.remoteTextTrackEls().length, 2, 'add html track element via method'); + + player.removeRemoteTextTrack(htmlTrackElement.track); + + equal(player.remoteTextTracks().length, 1, 'remove text track via method'); + equal(player.remoteTextTrackEls().length, 1, 'remove html track element via method'); + + player.dispose(); +}); + +test('should uniformly create html track element when adding text track', function () { + let player = TestHelpers.makePlayer(); + + let track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + + equal(player.remoteTextTrackEls().length, 0, 'no html text tracks'); + + let htmlTrackElement = player.addRemoteTextTrack(track); + + equal(htmlTrackElement.kind, htmlTrackElement.track.kind, 'verify html track element kind'); + equal(htmlTrackElement.src, htmlTrackElement.track.src, 'verify html track element src'); + equal(htmlTrackElement.srclang, htmlTrackElement.track.language, 'verify html track element language'); + equal(htmlTrackElement.label, htmlTrackElement.track.label, 'verify html track element label'); + equal(htmlTrackElement.default, htmlTrackElement.track.default, 'verify html track element default'); + + equal(player.remoteTextTrackEls().length, 1, 'html track element exist'); + equal(player.remoteTextTrackEls().getTrackElementByTrack_(htmlTrackElement.track), htmlTrackElement, 'verify same html track element'); + + player.dispose(); +});