From 9eb5de7ec70939d19afb1fc7884b99d87d500d97 Mon Sep 17 00:00:00 2001 From: Brandon Casey Date: Mon, 2 Apr 2018 16:06:26 -0400 Subject: [PATCH] fix: fire sourceset on initial source append (#5038) In Chrome/Firefox/Safari appending a element when the media element has no source, causes what we think of as a `sourceset`. These changes make our code actual fire that event. --- src/js/tech/html5.js | 4 +- src/js/tech/setup-sourceset.js | 316 +++++++++++++++++++++++-- test/unit/sourceset.test.js | 406 +++++++++++++++++++++++++++------ 3 files changed, 630 insertions(+), 96 deletions(-) diff --git a/src/js/tech/html5.js b/src/js/tech/html5.js index d860d4a24..587b62f9e 100644 --- a/src/js/tech/html5.js +++ b/src/js/tech/html5.js @@ -919,13 +919,15 @@ Html5.canControlPlaybackRate = function() { * - False otherwise */ Html5.canOverrideAttributes = function() { - // if we cannot overwrite the src property, there is no support + // if we cannot overwrite the src/innerHTML property, there is no support // iOS 7 safari for instance cannot do this. try { const noop = () => {}; Object.defineProperty(document.createElement('video'), 'src', {get: noop, set: noop}); Object.defineProperty(document.createElement('audio'), 'src', {get: noop, set: noop}); + Object.defineProperty(document.createElement('video'), 'innerHTML', {get: noop, set: noop}); + Object.defineProperty(document.createElement('audio'), 'innerHTML', {get: noop, set: noop}); } catch (e) { return false; } diff --git a/src/js/tech/setup-sourceset.js b/src/js/tech/setup-sourceset.js index 5bbd54c0a..73ad56488 100644 --- a/src/js/tech/setup-sourceset.js +++ b/src/js/tech/setup-sourceset.js @@ -1,21 +1,152 @@ import window from 'global/window'; +import document from 'global/document'; import mergeOptions from '../utils/merge-options'; -const setupSourceset = function(tech) { +/** + * This function is used to fire a sourceset when there is something + * similar to `mediaEl.load()` being called. It will try to find the source via + * the `src` attribute and then the `` elements. It will then fire `sourceset` + * with the source that was found or empty string if we cannot know. If it cannot + * find a source then `sourceset` will not be fired. + * + * @param {Html5} tech + * The tech object that sourceset was setup on + * + * @return {boolean} + * returns false if the sourceset was not fired and true otherwise. + */ +const sourcesetLoad = (tech) => { + const el = tech.el(); - if (!tech.featuresSourceset) { + // if `el.src` is set, that source will be loaded. + if (el.src) { + tech.triggerSourceset(el.src); + return true; + } + + /** + * Since there isn't a src property on the media element, source elements will be used for + * implementing the source selection algorithm. This happens asynchronously and + * for most cases were there is more than one source we cannot tell what source will + * be loaded, without re-implementing the source selection algorithm. At this time we are not + * going to do that. There are three special cases that we do handle here though: + * + * 1. If there are no sources, do not fire `sourceset`. + * 2. If there is only one `` with a `src` property/attribute that is our `src` + * 3. If there is more than one `` but all of them have the same `src` url. + * That will be our src. + */ + const sources = tech.$$('source'); + const srcUrls = []; + let src = ''; + + // if there are no sources, do not fire sourceset + if (!sources.length) { + return false; + } + + // only count valid/non-duplicate source elements + for (let i = 0; i < sources.length; i++) { + const url = sources[i].src; + + if (url && srcUrls.indexOf(url) === -1) { + srcUrls.push(url); + } + } + + // there were no valid sources + if (!srcUrls.length) { return; } - const el = tech.el(); - - // we need to fire sourceset when the player is ready - // if we find that the media element had a src when it was - // given to us and that tech element is not in a stalled state - if (el.src || el.currentSrc && tech.el().initNetworkState_ !== 3) { - tech.triggerSourceset(el.src || el.currentSrc); + // there is only one valid source element url + // use that + if (srcUrls.length === 1) { + src = srcUrls[0]; } + tech.triggerSourceset(src); + return true; +}; + +/** + * Get the browsers property descriptor for the `innerHTML` + * property. This will allow us to overwrite it without + * destroying native functionality. + * + * @param {HTMLMediaElement} el + * The tech element that should be used to get the descriptor + * + * @return {Object} + * The property descriptor for innerHTML. + */ +const getInnerHTMLDescriptor = (el) => { + const proto = window.Element.prototype; + let innerDescriptor = {}; + + // preserve getters/setters already on `el.innerHTML` if they exist + if (Object.getOwnPropertyDescriptor(el, 'innerHTML')) { + innerDescriptor = Object.getOwnPropertyDescriptor(el, 'innerHTML'); + } else if (Object.getOwnPropertyDescriptor(proto, 'innerHTML')) { + innerDescriptor = Object.getOwnPropertyDescriptor(proto, 'innerHTML'); + } + + if (!innerDescriptor.get) { + innerDescriptor.get = function() { + return el.cloneNode().innerHTML; + }; + } + + if (!innerDescriptor.set) { + innerDescriptor.set = function(v) { + // remove all current content from inside + el.innerText = ''; + + // make a dummy node to use innerHTML on + const dummy = document.createElement(el.nodeName.toLowerCase()); + + // set innerHTML to the value provided + dummy.innerHTML = v; + + // make a document fragment to hold the nodes from dummy + const docFrag = document.createDocumentFragment(); + + // copy all of the nodes created by the innerHTML on dummy + // to the document fragment + while (dummy.childNodes.length) { + docFrag.appendChild(dummy.childNodes[0]); + } + + // now we add all of that html in one by appending the + // document fragment. This is how innerHTML does it. + window.Element.prototype.appendChild.call(el, docFrag); + + // then return the result that innerHTML's setter would + return el.innerHTML; + }; + } + + if (typeof innerDescriptor.enumerable === 'undefined') { + innerDescriptor.enumerable = true; + } + + innerDescriptor.configurable = true; + + return innerDescriptor; +}; + +/** + * Get the browsers property descriptor for the `src` + * property. This will allow us to overwrite it without + * destroying native functionality. + * + * @param {HTMLMediaElement} el + * The tech element that should be used to get the descriptor + * + * @return {Object} + * The property descriptor for `src`. + */ +const getSrcDescriptor = (el) => { const proto = window.HTMLMediaElement.prototype; let srcDescriptor = {}; @@ -42,12 +173,158 @@ const setupSourceset = function(tech) { srcDescriptor.enumerable = true; } + srcDescriptor.configurable = true; + + return srcDescriptor; +}; + +/** + * Patches browser internal functions so that we can tell syncronously + * if a `` was appended to the media element. For some reason this + * causes a `sourceset` if the the media element is ready and has no source. + * This happens when: + * - The page has just loaded and the media element does not have a source. + * - The media element was emptied of all sources, then `load()` was called. + * + * It does this by patching the following functions/properties when they are supported: + * + * - `append()` - can be used to add a `` element to the media element + * - `appendChild()` - can be used to add a `` element to the media element + * - `insertAdjacentHTML()` - can be used to add a `` element to the media element + * - `innerHTML` - can be used to add a `` element to the media element + * + * @param {Html5} tech + * The tech object that sourceset is being setup on. + */ +const firstSourceWatch = function(tech) { + const el = tech.el(); + + // make sure firstSourceWatch isn't setup twice. + if (el.firstSourceWatch_) { + return; + } + + el.firstSourceWatch_ = true; + const oldAppend = el.append; + const oldAppendChild = el.appendChild; + const oldInsertAdjacentHTML = el.insertAdjacentHTML; + const innerDescriptor = getInnerHTMLDescriptor(el); + + el.appendChild = function() { + const retval = oldAppendChild.apply(el, arguments); + + sourcesetLoad(tech); + + return retval; + }; + + if (oldAppend) { + el.append = function() { + const retval = oldAppend.apply(el, arguments); + + sourcesetLoad(tech); + + return retval; + }; + } + + if (oldInsertAdjacentHTML) { + el.insertAdjacentHTML = function() { + const retval = oldInsertAdjacentHTML.apply(el, arguments); + + sourcesetLoad(tech); + + return retval; + }; + } + + Object.defineProperty(el, 'innerHTML', { + get: innerDescriptor.get.bind(el), + set(v) { + const retval = innerDescriptor.set.call(el, v); + + sourcesetLoad(tech); + + return retval; + }, + configurable: true, + enumerable: innerDescriptor.enumerable + }); + + // on the first sourceset, we need to revert + // our changes + tech.one('sourceset', (e) => { + el.firstSourceWatch_ = false; + el.appendChild = oldAppendChild; + + if (oldAppend) { + el.append = oldAppend; + } + if (oldInsertAdjacentHTML) { + el.insertAdjacentHTML = oldInsertAdjacentHTML; + } + + Object.defineProperty(el, 'innerHTML', innerDescriptor); + }); +}; + +/** + * setup `sourceset` handling on the `Html5` tech. This function + * patches the following element properties/functions: + * + * - `src` - to determine when `src` is set + * - `setAttribute()` - to determine when `src` is set + * - `load()` - this re-triggers the source selection algorithm, and can + * cause a sourceset. + * + * If there is no source when we are adding `sourceset` support or during a `load()` + * we also patch the functions listed in `firstSourceWatch`. + * + * @param {Html5} tech + * The tech to patch + */ +const setupSourceset = function(tech) { + if (!tech.featuresSourceset) { + return; + } + + const el = tech.el(); + + // make sure sourceset isn't setup twice. + if (el.setupSourceset_) { + return; + } + + el.setupSourceset_ = true; + + const srcDescriptor = getSrcDescriptor(el); + const oldSetAttribute = el.setAttribute; + const oldLoad = el.load; + + // we need to fire sourceset when the player is ready + // if we find that the media element had a src when it was + // given to us and that tech element is not in a stalled state + if (el.src || el.currentSrc && el.initNetworkState_ !== 3) { + if (el.currentSrc) { + tech.triggerSourceset(el.currentSrc); + } else { + sourcesetLoad(tech); + } + } + + // for some reason adding a source element when a mediaElement has no source + // calls `load` internally right away. We need to handle that. + if (!el.src && !el.currentSrc && !tech.$$('source').length) { + firstSourceWatch(tech); + } + Object.defineProperty(el, 'src', { get: srcDescriptor.get.bind(el), set: (v) => { const retval = srcDescriptor.set.call(el, v); - tech.triggerSourceset(v); + // we use the getter here to get the actual value set on src + tech.triggerSourceset(el.src); return retval; }, @@ -55,29 +332,26 @@ const setupSourceset = function(tech) { enumerable: srcDescriptor.enumerable }); - const oldSetAttribute = el.setAttribute; - el.setAttribute = (n, v) => { const retval = oldSetAttribute.call(el, n, v); if (n === 'src') { - tech.triggerSourceset(v); + tech.triggerSourceset(el.getAttribute('src')); } return retval; }; - const oldLoad = el.load; - el.load = () => { const retval = oldLoad.call(el); - // if `el.src` is set, that source will be loaded - // otherwise, we can't know for sure what source will be set because - // source elements will be used but implementing the source selection algorithm - // is laborious and asynchronous, so, - // instead return an empty string to basically indicate source may change - tech.triggerSourceset(el.src || ''); + // if load was called, but there was no source to fire + // sourceset on. We have to watch for a source append + // as that can trigger a `sourceset` when the media element + // has no source + if (!sourcesetLoad(tech)) { + firstSourceWatch(tech); + } return retval; }; diff --git a/test/unit/sourceset.test.js b/test/unit/sourceset.test.js index d2ed1d2ed..ad2d7385f 100644 --- a/test/unit/sourceset.test.js +++ b/test/unit/sourceset.test.js @@ -161,51 +161,6 @@ QUnit[qunitFn]('sourceset', function(hooks) { }); }); - QUnit.test('player.src({...}) one source', function(assert) { - const done = assert.async(); - - this.player = videojs(this.mediaEl, { - enableSourceset: true - }); - this.player.one('sourceset', () => { - validateSource(assert, this.player, [this.testSrc]); - done(); - }); - - this.player.src(this.testSrc); - }); - - QUnit.test('player.src({...}) preload auto', function(assert) { - const done = assert.async(); - - this.mediaEl.setAttribute('preload', 'auto'); - this.player = videojs(this.mediaEl, { - enableSourceset: true - }); - - this.player.one('sourceset', () => { - validateSource(assert, this.player, [this.testSrc]); - done(); - }); - - this.player.src(this.testSrc); - }); - - QUnit.test('player.src({...}) two sources', function(assert) { - const done = assert.async(); - - this.player = videojs(this.mediaEl, { - enableSourceset: true - }); - - this.player.one('sourceset', () => { - validateSource(assert, this.player, [this.sourceOne, this.sourceTwo]); - done(); - }); - - this.player.src([this.sourceOne, this.sourceTwo]); - }); - QUnit.test('mediaEl.src = ...;', function(assert) { const done = assert.async(); @@ -292,6 +247,320 @@ QUnit[qunitFn]('sourceset', function(hooks) { }); })); + QUnit.module('source after player', (subhooks) => testTypes.forEach((testName) => { + QUnit.module(testName, { + beforeEach() { + sinon.stub(log, 'error'); + + setupEnv(this, testName); + }, + afterEach: setupAfterEach(1) + }); + + QUnit.test('player.src({...}) one source', function(assert) { + const done = assert.async(); + + this.player = videojs(this.mediaEl, { + enableSourceset: true + }); + this.player.one('sourceset', () => { + validateSource(assert, this.player, [this.testSrc]); + done(); + }); + + this.player.src(this.testSrc); + }); + + QUnit.test('player.src({...}) preload auto', function(assert) { + const done = assert.async(); + + this.mediaEl.setAttribute('preload', 'auto'); + this.player = videojs(this.mediaEl, { + enableSourceset: true + }); + + this.player.one('sourceset', () => { + validateSource(assert, this.player, [this.testSrc]); + done(); + }); + + this.player.src(this.testSrc); + }); + + QUnit.test('player.src({...}) two sources', function(assert) { + const done = assert.async(); + + this.player = videojs(this.mediaEl, { + enableSourceset: true + }); + + this.player.one('sourceset', () => { + validateSource(assert, this.player, [this.sourceOne, this.sourceTwo]); + done(); + }); + + this.player.src([this.sourceOne, this.sourceTwo]); + }); + + QUnit.test('mediaEl.src = ...;', function(assert) { + const done = assert.async(); + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + validateSource(assert, this.player, [this.testSrc]); + done(); + }); + + this.player.tech_.el_.src = this.testSrc.src; + }); + + QUnit.test('mediaEl.setAttribute("src", ...)"', function(assert) { + const done = assert.async(); + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + validateSource(assert, this.player, [this.testSrc]); + done(); + }); + + this.player.tech_.el_.setAttribute('src', this.testSrc.src); + }); + + const appendTypes = [ + {name: 'appendChild', fn: (el, obj) => el.appendChild(obj)}, + {name: 'innerHTML', fn: (el, obj) => {el.innerHTML = obj.outerHTML;}}, // eslint-disable-line + ]; + + // ie does not support this and safari < 10 does not either + if (window.Element.prototype.append) { + appendTypes.push({name: 'append', fn: (el, obj) => el.append(obj)}); + } + + if (window.Element.prototype.insertAdjacentHTML) { + appendTypes.push({name: 'insertAdjacentHTML', fn: (el, obj) => el.insertAdjacentHTML('afterbegin', obj.outerHTML)}); + } + + appendTypes.forEach((appendObj) => { + + QUnit.test(` one source through ${appendObj.name}`, function(assert) { + const done = assert.async(); + + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + assert.equal(e.src, this.testSrc.src, 'source is as expected'); + done(); + }); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + }); + + QUnit.test(` one source through ${appendObj.name} and load`, function(assert) { + const done = assert.async(); + + this.totalSourcesets = 2; + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e1) => { + assert.equal(e1.src, this.testSrc.src, 'event has expected source'); + + this.player.one('sourceset', (e2) => { + assert.equal(e2.src, this.testSrc.src, 'second event has expected source'); + done(); + }); + }); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + + // should fire an additional sourceset + this.player.tech_.el_.load(); + }); + + QUnit.test(`one through ${appendObj.name} and then mediaEl.src`, function(assert) { + const done = assert.async(); + + this.totalSourcesets = 2; + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + assert.equal(e.src, this.testSrc.src, 'source is as expected'); + + this.player.one('sourceset', (e2) => { + validateSource(assert, this.player, [this.sourceOne]); + + done(); + }); + }); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + + // should fire an additional sourceset + this.player.tech_.el_.src = this.sourceOne.src; + }); + + QUnit.test(`one through ${appendObj.name} and then mediaEl.setAttribute`, function(assert) { + const done = assert.async(); + + this.totalSourcesets = 2; + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + assert.equal(e.src, this.testSrc.src, 'source is as expected'); + + this.player.one('sourceset', (e2) => { + validateSource(assert, this.player, [this.sourceOne]); + + done(); + }); + }); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + + // should fire an additional sourceset + this.player.tech_.el_.setAttribute('src', this.sourceOne.src); + }); + + QUnit.test(`mediaEl.src and then through ${appendObj.name}`, function(assert) { + const done = assert.async(); + + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + validateSource(assert, this.player, [this.sourceOne]); + + done(); + }); + + this.player.tech_.el_.src = this.sourceOne.src; + + // should not fire sourceset + appendObj.fn(this.player.tech_.el_, this.source); + }); + + QUnit.test(`mediaEl.setAttribute and then through ${appendObj.name}`, function(assert) { + const done = assert.async(); + + this.source = document.createElement('source'); + this.source.src = this.testSrc.src; + this.source.type = this.testSrc.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + validateSource(assert, this.player, [this.sourceOne]); + + done(); + }); + + this.player.tech_.el_.setAttribute('src', this.sourceOne.src); + + // should not fire sourceset + appendObj.fn(this.player.tech_.el_, this.source); + }); + + QUnit.test(` two sources through ${appendObj.name}`, function(assert) { + const done = assert.async(); + + this.source = document.createElement('source'); + this.source.src = this.sourceOne.src; + this.source.type = this.sourceOne.type; + + this.source2 = document.createElement('source'); + this.source2.src = this.sourceTwo.src; + this.source2.type = this.sourceTwo.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e) => { + assert.equal(e.src, this.sourceOne.src, 'source is as expected'); + done(); + }); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + + // this should not be in the source list or fire a sourceset + appendObj.fn(this.player.tech_.el_, this.source2); + }); + + QUnit.test(`set, remove, load, and set again through ${appendObj.name}`, function(assert) { + const done = assert.async(); + + this.totalSourcesets = 2; + this.source = document.createElement('source'); + this.source.src = this.sourceTwo.src; + this.source.type = this.sourceTwo.type; + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + + this.player.one('sourceset', (e1) => { + validateSource(assert, this.player, [this.sourceOne]); + + this.player.one('sourceset', (e2) => { + validateSource(assert, this.player, [this.sourceTwo], false); + done(); + }); + + // reset to no source + this.player.tech_.el_.removeAttribute('src'); + this.player.tech_.el_.load(); + + // since the media el has no source, just appending will + // change the source without calling load + appendObj.fn(this.player.tech_.el_, this.source); + + }); + + this.player.tech_.el_.setAttribute('src', this.sourceOne.src); + }); + }); + + QUnit.test('no source and load', function(assert) { + const done = assert.async(); + + this.player = videojs(this.mediaEl, {enableSourceset: true}); + this.player.tech_.el_.load(); + + this.totalSourcesets = 0; + + window.setTimeout(() => { + assert.equal(this.sourcesets, 0, 'no sourceset'); + done(); + }, wait); + }); + })); + QUnit.module('source change', (subhooks) => testTypes.forEach((testName) => { QUnit.module(testName, { beforeEach(assert) { @@ -451,10 +720,10 @@ QUnit[qunitFn]('sourceset', function(hooks) { this.mediaEl.removeAttribute('src'); this.player.one('sourceset', (e1) => { - assert.equal(e1.src, '', 'we got a sourceset with an empty src'); + assert.equal(e1.src, this.testSrc.src, 'we got a sourceset with the expected src'); this.player.one('sourceset', (e2) => { - assert.equal(e2.src, '', 'we got a sourceset with an empty src'); + assert.equal(e2.src, this.sourceOne.src, 'we got a sourceset with the expected src'); }); source.src = this.sourceOne.src; @@ -474,10 +743,10 @@ QUnit[qunitFn]('sourceset', function(hooks) { source.type = this.sourceOne.type; this.player.one('sourceset', (e1) => { - assert.equal(e1.src, '', 'we got a sourceset with an empty src'); + assert.equal(e1.src, this.sourceOne.src, 'we got a sourceset with the expected src'); this.player.one('sourceset', (e2) => { - assert.equal(e2.src, '', 'we got a sourceset with an empty src'); + assert.equal(e2.src, this.sourceTwo.src, 'we got a sourceset with the expected src'); }); }); @@ -538,7 +807,7 @@ QUnit[qunitFn]('sourceset', function(hooks) { src: 'http://example.com/oceans.flv', type: 'video/flv' }; - let sourcesets = 0; + const sourcesets = []; class FakeFlash extends Html5 { static isSupported() { @@ -563,38 +832,27 @@ QUnit[qunitFn]('sourceset', function(hooks) { techOrder: ['fakeFlash', 'html5'] }); - player.src(flashSrc); - player.ready(function() { - // the first sourceset comes from our FakeFlash because it extends Html5 tech - // which calls load() on dispose for various reasons - player.one('sourceset', function(e1) { - // ignore the first sourceset that gets called when disposing the original tech + // the first sourceset ends up being the second source because when the first source is set + // the tech isn't ready so we delay it, then the second source comes and the tech is ready + // so it ends up being triggered immediately. + player.on('sourceset', (e) => { + sourcesets.push(e.src); - // the second sourceset ends up being the second source because when the first source is set - // the tech isn't ready so we delay it, then the second source comes and the tech is ready - // so it ends up being triggered immediately. - player.one('sourceset', function(e2) { - assert.equal(e2.src, sourceTwo.src, 'the second sourceset ends up being the second source'); - sourcesets++; + if (sourcesets.length === 3) { + assert.deepEqual([flashSrc.src, sourceTwo.src, sourceOne.src], sourcesets, 'sourceset as expected'); - // now that the tech is ready, we will re-trigger the original sourceset event - // and get the first source - player.one('sourceset', function(e3) { - assert.equal(e3.src, sourceOne.src, 'the third sourceset is the first source'); - sourcesets++; - - assert.equal(sourcesets, 2, 'two sourcesets'); - player.dispose(); - delete Tech.techs_.FakeFlash; - done(); - }); - }); + player.dispose(); + delete Tech.techs_.FakeFlash; + done(); + } }); player.src(sourceOne); player.src(sourceTwo); }); + player.src(flashSrc); + }); });