1
0
mirror of https://github.com/videojs/video.js.git synced 2025-01-10 23:30:03 +02:00

@gkatsev always use emulated TextTrackLists so tracks survive tech switches. closes #2425

This commit is contained in:
Gary Katsevman 2015-08-03 15:19:36 -04:00 committed by David LaPalomento
parent 7d335e0989
commit 2dfd315ac1
19 changed files with 518 additions and 17 deletions

View File

@ -85,6 +85,7 @@ CHANGELOG
* @sirlancelot change "video" to "media" in error messages ([view](https://github.com/videojs/video.js/pull/2409))
* @sirlancelot change "video" to "media" in error messages ([view](https://github.com/videojs/video.js/pull/2409))
* @nickygerritsen use the default seekable when a source handler is unset ([view](https://github.com/videojs/video.js/pull/2401))
* @gkatsev always use emulated TextTrackLists so tracks survive tech switches ([view](https://github.com/videojs/video.js/pull/2425))
--------------------

View File

@ -5,11 +5,11 @@
"copyright": "Copyright Brightcove, Inc. <https://www.brightcove.com/>",
"license": "Apache-2.0",
"keywords": [
"videojs",
"html5",
"flash",
"html5",
"player",
"video",
"player"
"videojs"
],
"homepage": "http://videojs.com",
"author": "Steve Heffernan",
@ -79,7 +79,7 @@
"karma-sauce-launcher": "^0.2.8",
"karma-sinon": "^1.0.3",
"load-grunt-tasks": "^3.1.0",
"qunitjs": "~1.14.0",
"qunitjs": "^1.18.0",
"sinon": "~1.9.1",
"time-grunt": "^1.1.1",
"uglify-js": "~2.3.6",
@ -87,13 +87,13 @@
},
"standard": {
"ignore": [
"**/Gruntfile.js",
"**/build/**",
"**/dist/**",
"**/docs/**",
"**/lang/**",
"**/sandbox/**",
"**/test/**",
"**/Gruntfile.js"
"**/test/**"
]
}
}

View File

@ -21,6 +21,7 @@ import globalOptions from './global-options.js';
import safeParseTuple from 'safe-json-parse/tuple';
import assign from 'object.assign';
import mergeOptions from './utils/merge-options.js';
import textTrackConverter from './tracks/text-track-list-converter.js';
// Include required child components (importing also registers them)
import MediaLoader from './tech/loader.js';
@ -524,6 +525,8 @@ class Player extends Component {
let techComponent = Component.getComponent(techName);
this.tech = new techComponent(techOptions);
textTrackConverter.jsonToTextTracks(this.textTracksJson_ || [], this.tech);
this.on(this.tech, 'ready', this.handleTechReady);
this.on(this.tech, 'usenativecontrols', this.handleTechUseNativeControls);
@ -583,6 +586,7 @@ class Player extends Component {
unloadTech() {
// Save the current text tracks so that we can reuse the same text tracks with the next tech
this.textTracks_ = this.textTracks();
this.textTracksJson_ = textTrackConverter.textTracksToJson(this);
this.isReady_ = false;

View File

@ -67,6 +67,11 @@ class Html5 extends Tech {
if (this.featuresNativeTextTracks) {
this.on('loadstart', Fn.bind(this, this.hideCaptions));
this.handleTextTrackChange_ = Fn.bind(this, this.handleTextTrackChange);
this.handleTextTrackAdd_ = Fn.bind(this, this.handleTextTrackAdd);
this.handleTextTrackRemove_ = Fn.bind(this, this.handleTextTrackRemove);
this.proxyNativeTextTracks_();
}
// Determine if native controls should be used
@ -86,6 +91,22 @@ class Html5 extends Tech {
* @method dispose
*/
dispose() {
let tt = this.el().textTracks;
let emulatedTt = this.textTracks();
// remove native event listeners
tt.removeEventListener('change', this.handleTextTrackChange_);
tt.removeEventListener('addtrack', this.handleTextTrackAdd_);
tt.removeEventListener('removetrack', this.handleTextTrackRemove_);
// clearout the emulated text track list.
let i = emulatedTt.length;
while (i--) {
emulatedTt.removeTrack_(emulatedTt[i]);
}
Html5.disposeMediaElement(this.el_);
super.dispose();
}
@ -182,6 +203,32 @@ class Html5 extends Tech {
}
}
proxyNativeTextTracks_() {
let tt = this.el().textTracks;
tt.addEventListener('change', this.handleTextTrackChange_);
tt.addEventListener('addtrack', this.handleTextTrackAdd_);
tt.addEventListener('removetrack', this.handleTextTrackRemove_);
}
handleTextTrackChange(e) {
let tt = this.textTracks();
this.textTracks().trigger({
type: 'change',
target: tt,
currentTarget: tt,
srcElement: tt
});
}
handleTextTrackAdd(e) {
this.textTracks().addTrack_(e.track);
}
handleTextTrackRemove(e) {
this.textTracks().removeTrack_(e.track);
}
/**
* Play for html5 tech
*
@ -593,11 +640,7 @@ class Html5 extends Tech {
* @method textTracks
*/
textTracks() {
if (!this['featuresNativeTextTracks']) {
return super.textTracks();
}
return this.el_.textTracks;
return super.textTracks();
}
/**
@ -692,12 +735,12 @@ class Html5 extends Tech {
this.remoteTextTracks().removeTrack_(track);
tracks = this.el()['querySelectorAll']('track');
tracks = this.el().querySelectorAll('track');
for (i = 0; i < tracks.length; i++) {
if (tracks[i] === track || tracks[i]['track'] === track) {
tracks[i]['parentNode']['removeChild'](tracks[i]);
break;
i = tracks.length;
while (i--) {
if (track === tracks[i] || track === tracks[i].track) {
this.el().removeChild(tracks[i]);
}
}
}

View File

@ -249,6 +249,14 @@ class Tech extends Component {
* @method dispose
*/
dispose() {
// clear out text tracks because we can't reuse them between techs
let tt = this.textTracks();
let i = tt.length;
while(i--) {
this.removeRemoteTextTrack(tt[i]);
}
// Turn off any manual progress or timeupdate tracking
if (this.manualProgress) { this.manualProgressOff(); }

View File

@ -0,0 +1,77 @@
/**
* Utilities for capturing text track state and re-creating tracks
* based on a capture.
*
* @file text-track-list-converter.js
*/
/**
* Examine a single text track and return a JSON-compatible javascript
* object that represents the text track's state.
* @param track {TextTrackObject} the text track to query
* @return {Object} a serializable javascript representation of the
* @private
*/
let trackToJson_ = function(track) {
return {
kind: track.kind,
label: track.label,
language: track.language,
id: track.id,
inBandMetadataTrackDispatchType: track.inBandMetadataTrackDispatchType,
mode: track.mode,
cues: track.cues && Array.prototype.map.call(track.cues, function(cue) {
return {
startTime: cue.startTime,
endTime: cue.endTime,
text: cue.text,
id: cue.id
};
}),
src: track.src
};
};
/**
* Examine a tech and return a JSON-compatible javascript array that
* represents the state of all text tracks currently configured. The
* return array is compatible with `jsonToTextTracks`.
* @param tech {tech} the tech object to query
* @return {Array} a serializable javascript representation of the
* @function textTracksToJson
*/
let textTracksToJson = function(tech) {
let trackEls = tech.el().querySelectorAll('track');
let trackObjs = Array.prototype.map.call(trackEls, (t) => t.track);
let tracks = Array.prototype.map.call(trackEls, function(trackEl) {
let json = trackToJson_(trackEl.track);
json.src = trackEl.src;
return json;
});
return tracks.concat(Array.prototype.filter.call(tech.textTracks(), function(track) {
return trackObjs.indexOf(track) === -1;
}).map(trackToJson_));
};
/**
* Creates a set of remote text tracks on a tech based on an array of
* javascript text track representations.
* @param json {Array} an array of text track representation objects,
* like those that would be produced by `textTracksToJson`
* @param tech {tech} the tech to create text tracks on
* @function jsonToTextTracks
*/
let jsonToTextTracks = function(json, tech) {
json.forEach(function(track) {
let addedTrack = tech.addRemoteTextTrack(track).track;
if (!track.src && track.cues) {
track.cues.forEach((cue) => addedTrack.addCue(cue));
}
});
return tech.textTracks();
};
export default {textTracksToJson, jsonToTextTracks, trackToJson_};

View File

@ -176,6 +176,7 @@ let TextTrack = function(options={}) {
});
if (options.src) {
tt.src = options.src;
loadTrack(options.src, tt);
} else {
tt.loaded_ = true;

View File

@ -93,6 +93,7 @@ test('dispose removes the object element even before ready fires', function() {
mockFlash.off = noop;
mockFlash.trigger = noop;
mockFlash.el_ = {};
mockFlash.textTracks = () => ([]);
dispose.call(mockFlash);
strictEqual(mockFlash.el_, null, 'swf el is nulled');

View File

@ -183,3 +183,48 @@ test('native source handler canHandleSource', function(){
// Reset test video canPlayType
Html5.TEST_VID.canPlayType = origCPT;
});
if (Html5.supportsNativeTextTracks()) {
test('add native textTrack listeners on startup', function() {
let adds = [];
let rems = [];
let tt = {
length: 0,
addEventListener: (type, fn) => adds.push([type, fn]),
removeEventListener: (type, fn) => rems.push([type, fn]),
};
let el = document.createElement('div');
el.textTracks = tt;
let htmlTech = new Html5({el});
equal(adds[0][0], 'change', 'change event handler added');
equal(adds[1][0], 'addtrack', 'addtrack event handler added');
equal(adds[2][0], 'removetrack', 'removetrack event handler added');
});
test('remove all tracks from emulated list on dispose', function() {
let adds = [];
let rems = [];
let tt = {
length: 0,
addEventListener: (type, fn) => adds.push([type, fn]),
removeEventListener: (type, fn) => rems.push([type, fn]),
};
let el = document.createElement('div');
el.textTracks = tt;
let htmlTech = new Html5({el});
htmlTech.dispose();
equal(adds[0][0], 'change', 'change event handler added');
equal(adds[1][0], 'addtrack', 'addtrack event handler added');
equal(adds[2][0], 'removetrack', 'removetrack event handler added');
equal(rems[0][0], 'change', 'change event handler removed');
equal(rems[1][0], 'addtrack', 'addtrack event handler removed');
equal(rems[2][0], 'removetrack', 'removetrack event handler removed');
equal(adds[0][0], rems[0][0], 'change event handler removed');
equal(adds[1][0], rems[1][0], 'addtrack event handler removed');
equal(adds[2][0], rems[2][0], 'removetrack event handler removed');
});
}

View File

@ -0,0 +1,250 @@
import c from '../../../src/js/tracks/text-track-list-converter.js';
import TextTrack from '../../../src/js/tracks/text-track.js';
import TextTrackList from '../../../src/js/tracks/text-track-list.js';
import Html5 from '../../../src/js/tech/html5.js';
import document from 'global/document';
q.module('Text Track List Converter');
let clean = (item) => {
delete item.id;
delete item.inBandMetadataTrackDispatchType;
};
let cleanup = (item) => {
if (Array.isArray(item)) {
item.forEach(clean);
} else {
clean(item);
}
return item;
};
if (Html5.supportsNativeTextTracks()) {
q.test('trackToJson_ produces correct representation for native track object', function(a) {
let track = document.createElement('track');
track.src = 'http://example.com/english.vtt';
track.kind = 'captions';
track.srclang = 'en';
track.label = 'English';
a.deepEqual(cleanup(c.trackToJson_(track.track)), {
src: undefined,
kind: 'captions',
label: 'English',
language: 'en',
mode: 'disabled',
cues: null
}, 'the json output is same');
});
q.test('textTracksToJson produces good json output', function(a) {
let emulatedTrack = new TextTrack({
kind: 'captions',
label: 'English',
language: 'en',
src: 'http://example.com/english.vtt',
tech: {}
});
let nativeTrack = document.createElement('track');
nativeTrack.src = 'http://example.com/spanish.vtt';
nativeTrack.kind = 'captions';
nativeTrack.srclang = 'es';
nativeTrack.label = 'Spanish';
let tt = new TextTrackList();
tt.addTrack_(nativeTrack.track);
tt.addTrack_(emulatedTrack);
let tech = {
el() {
return {
querySelectorAll() {
return [nativeTrack];
}
};
},
textTracks() {
return tt;
}
};
a.deepEqual(cleanup(c.textTracksToJson(tech)), [{
src: 'http://example.com/spanish.vtt',
kind: 'captions',
label: 'Spanish',
language: 'es',
mode: 'disabled',
cues: null
}, {
src: 'http://example.com/english.vtt',
kind: 'captions',
label: 'English',
language: 'en',
mode: 'disabled',
cues: null
}], 'the output is correct');
});
q.test('jsonToTextTracks calls addRemoteTextTrack on the tech with mixed tracks', function(a) {
let emulatedTrack = new TextTrack({
kind: 'captions',
label: 'English',
language: 'en',
src: 'http://example.com/english.vtt',
tech: {}
});
let nativeTrack = document.createElement('track');
nativeTrack.src = 'http://example.com/spanish.vtt';
nativeTrack.kind = 'captions';
nativeTrack.srclang = 'es';
nativeTrack.label = 'Spanish';
let tt = new TextTrackList();
tt.addTrack_(nativeTrack.track);
tt.addTrack_(emulatedTrack);
let addRemotes = 0;
let tech = {
el() {
return {
querySelectorAll() {
return [nativeTrack];
}
};
},
textTracks() {
return tt;
},
addRemoteTextTrack() {
addRemotes++;
return {
track: {}
};
}
};
c.jsonToTextTracks(cleanup(c.textTracksToJson(tech)), tech);
a.equal(addRemotes, 2, 'we added two text tracks');
});
}
q.test('trackToJson_ produces correct representation for emulated track object', function(a) {
let track = new TextTrack({
kind: 'captions',
label: 'English',
language: 'en',
src: 'http://example.com/english.vtt',
tech: {}
});
a.deepEqual(cleanup(c.trackToJson_(track)), {
src: 'http://example.com/english.vtt',
kind: 'captions',
label: 'English',
language: 'en',
mode: 'disabled',
cues: null
}, 'the json output is same');
});
q.test('textTracksToJson produces good json output for emulated only', function(a) {
let emulatedTrack = new TextTrack({
kind: 'captions',
label: 'English',
language: 'en',
src: 'http://example.com/english.vtt',
tech: {}
});
let anotherTrack = new TextTrack({
src: 'http://example.com/spanish.vtt',
kind: 'captions',
srclang: 'es',
label: 'Spanish',
tech: {}
});
let tt = new TextTrackList();
tt.addTrack_(anotherTrack);
tt.addTrack_(emulatedTrack);
let tech = {
el() {
return {
querySelectorAll() {
return [];
}
};
},
textTracks() {
return tt;
}
};
a.deepEqual(cleanup(c.textTracksToJson(tech)), [{
src: 'http://example.com/spanish.vtt',
kind: 'captions',
label: 'Spanish',
language: 'es',
mode: 'disabled',
cues: null
}, {
src: 'http://example.com/english.vtt',
kind: 'captions',
label: 'English',
language: 'en',
mode: 'disabled',
cues: null
}], 'the output is correct');
});
q.test('jsonToTextTracks calls addRemoteTextTrack on the tech with emulated tracks only', function(a) {
let emulatedTrack = new TextTrack({
kind: 'captions',
label: 'English',
language: 'en',
src: 'http://example.com/english.vtt',
tech: {}
});
let anotherTrack = new TextTrack({
src: 'http://example.com/spanish.vtt',
kind: 'captions',
srclang: 'es',
label: 'Spanish',
tech: {}
});
let tt = new TextTrackList();
tt.addTrack_(anotherTrack);
tt.addTrack_(emulatedTrack);
let addRemotes = 0;
let tech = {
el() {
return {
querySelectorAll() {
return [];
}
};
},
textTracks() {
return tt;
},
addRemoteTextTrack() {
addRemotes++;
return {
track: {}
};
}
};
c.jsonToTextTracks(cleanup(c.textTracksToJson(tech)), tech);
a.equal(addRemotes, 2, 'we added two text tracks');
});

View File

@ -307,3 +307,56 @@ test('when switching techs, we should not get a new text track', function() {
ok(htmltracks === flashtracks, 'the tracks are equal');
});
if (Html5.supportsNativeTextTracks()) {
test('listen to remove and add track events in native text tracks', function(assert) {
let done = assert.async();
let el = document.createElement('video');
let html = new Html5({el});
let tt = el.textTracks;
let emulatedTt = html.textTracks();
let track = document.createElement('track');
el.appendChild(track);
let addtrack = function() {
equal(emulatedTt.length, tt.length, 'we have matching tracks length');
equal(emulatedTt.length, 1, 'we have one text track');
emulatedTt.off('addtrack', addtrack);
el.removeChild(track);
};
emulatedTt.on('addtrack', addtrack);
emulatedTt.on('removetrack', function() {
equal(emulatedTt.length, tt.length, 'we have matching tracks length');
equal(emulatedTt.length, 0, 'we have no more text tracks');
done();
});
});
test('should have removed tracks on dispose', function(assert) {
let done = assert.async();
let el = document.createElement('video');
let html = new Html5({el});
let tt = el.textTracks;
let emulatedTt = html.textTracks();
let track = document.createElement('track');
el.appendChild(track);
let addtrack = function() {
equal(emulatedTt.length, tt.length, 'we have matching tracks length');
equal(emulatedTt.length, 1, 'we have one text track');
emulatedTt.off('addtrack', addtrack);
html.dispose();
equal(emulatedTt.length, tt.length, 'we have matching tracks length');
equal(emulatedTt.length, 0, 'we have no more text tracks');
done();
};
emulatedTt.on('addtrack', addtrack);
});
}

View File

@ -2,6 +2,8 @@ import document from 'global/document';
import * as Dom from '../../../src/js/utils/dom.js';
import TestHelpers from '../test-helpers.js';
q.module('dom');
test('should return the element with the ID', function(){
var el1 = document.createElement('div');
var el2 = document.createElement('div');

View File

@ -1,5 +1,7 @@
import * as Fn from '../../../src/js/utils/fn.js';
q.module('fn');
test('should add context to a function', function(){
var newContext = { test: 'obj'};
var asdf = function(){

View File

@ -1,5 +1,7 @@
import formatTime from '../../../src/js/utils/format-time.js';
q.module('format-time');
test('should format time as a string', function(){
ok(formatTime(1) === '0:01');
ok(formatTime(10) === '0:10');

View File

@ -1,6 +1,8 @@
import log from '../../../src/js/utils/log.js';
import window from 'global/window';
q.module('log');
test('should confirm logging functions work', function() {
let origConsole = window['console'];
// replace the native console for testing

View File

@ -1,5 +1,7 @@
import mergeOptions from '../../../src/js/utils/merge-options.js';
q.module('merge-options');
test('should merge options objects', function(){
var ob1, ob2, ob3;

View File

@ -1,5 +1,7 @@
import { createTimeRange } from '../../../src/js/utils/time-ranges.js';
q.module('time-ranges');
test('should create a fake timerange', function(){
var tr = createTimeRange(0, 10);
ok(tr.start() === 0);

View File

@ -1,5 +1,7 @@
import toTitleCase from '../../../src/js/utils/to-title-case.js';
q.module('to-title-case');
test('should make a string start with an uppercase letter', function(){
var foo = toTitleCase('bar');
ok(foo === 'Bar');

View File

@ -2,6 +2,8 @@ import document from 'global/document';
import window from 'global/window';
import * as Url from '../../../src/js/utils/url.js';
q.module('url');
test('should parse the details of a url correctly', function(){
equal(Url.parseUrl('#').protocol, window.location.protocol, 'parsed relative url protocol');
equal(Url.parseUrl('#').host, window.location.host, 'parsed relative url host');
@ -30,9 +32,11 @@ test('should strip port from hosts using http or https', function() {
};
url = Url.parseUrl('/domain/relative/url');
ok(!(/.*:80$/).test(url.host), ':80 is not appended to the host');
document.createElement = origDocCreate;
ok(!(/.*:80$/).test(url.host), ':80 is not appended to the host');
});
test('should get an absolute URL', function(){