Skip to content

Commit 00e7f7b

Browse files
fix: fire sourceset on initial source append (#5038) (#5072)
In Chrome/Firefox/Safari appending a <source> 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. `innerHTML` does not append <source> on IE9 so tests for that are removed in this 6.x commit
1 parent c04dac4 commit 00e7f7b

File tree

3 files changed

+629
-90
lines changed

3 files changed

+629
-90
lines changed

src/js/tech/html5.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,13 +922,15 @@ Html5.canOverrideAttributes = function() {
922922
if (browser.IS_IE8) {
923923
return false;
924924
}
925-
// if we cannot overwrite the src property, there is no support
925+
// if we cannot overwrite the src/innerHTML property, there is no support
926926
// iOS 7 safari for instance cannot do this.
927927
try {
928928
const noop = () => {};
929929

930930
Object.defineProperty(document.createElement('video'), 'src', {get: noop, set: noop});
931931
Object.defineProperty(document.createElement('audio'), 'src', {get: noop, set: noop});
932+
Object.defineProperty(document.createElement('video'), 'innerHTML', {get: noop, set: noop});
933+
Object.defineProperty(document.createElement('audio'), 'innerHTML', {get: noop, set: noop});
932934
} catch (e) {
933935
return false;
934936
}

src/js/tech/setup-sourceset.js

Lines changed: 294 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,152 @@
11
import window from 'global/window';
2+
import document from 'global/document';
23
import mergeOptions from '../utils/merge-options';
34

4-
const setupSourceset = function(tech) {
5+
/**
6+
* This function is used to fire a sourceset when there is something
7+
* similar to `mediaEl.load()` being called. It will try to find the source via
8+
* the `src` attribute and then the `<source>` elements. It will then fire `sourceset`
9+
* with the source that was found or empty string if we cannot know. If it cannot
10+
* find a source then `sourceset` will not be fired.
11+
*
12+
* @param {Html5} tech
13+
* The tech object that sourceset was setup on
14+
*
15+
* @return {boolean}
16+
* returns false if the sourceset was not fired and true otherwise.
17+
*/
18+
const sourcesetLoad = (tech) => {
19+
const el = tech.el();
520

6-
if (!tech.featuresSourceset) {
21+
// if `el.src` is set, that source will be loaded.
22+
if (el.src) {
23+
tech.triggerSourceset(el.src);
24+
return true;
25+
}
26+
27+
/**
28+
* Since there isn't a src property on the media element, source elements will be used for
29+
* implementing the source selection algorithm. This happens asynchronously and
30+
* for most cases were there is more than one source we cannot tell what source will
31+
* be loaded, without re-implementing the source selection algorithm. At this time we are not
32+
* going to do that. There are three special cases that we do handle here though:
33+
*
34+
* 1. If there are no sources, do not fire `sourceset`.
35+
* 2. If there is only one `<source>` with a `src` property/attribute that is our `src`
36+
* 3. If there is more than one `<source>` but all of them have the same `src` url.
37+
* That will be our src.
38+
*/
39+
const sources = tech.$$('source');
40+
const srcUrls = [];
41+
let src = '';
42+
43+
// if there are no sources, do not fire sourceset
44+
if (!sources.length) {
45+
return false;
46+
}
47+
48+
// only count valid/non-duplicate source elements
49+
for (let i = 0; i < sources.length; i++) {
50+
const url = sources[i].src;
51+
52+
if (url && srcUrls.indexOf(url) === -1) {
53+
srcUrls.push(url);
54+
}
55+
}
56+
57+
// there were no valid sources
58+
if (!srcUrls.length) {
759
return;
860
}
961

10-
const el = tech.el();
62+
// there is only one valid source element url
63+
// use that
64+
if (srcUrls.length === 1) {
65+
src = srcUrls[0];
66+
}
1167

12-
// we need to fire sourceset when the player is ready
13-
// if we find that the media element had a src when it was
14-
// given to us and that tech element is not in a stalled state
15-
if (el.src || el.currentSrc && tech.el().initNetworkState_ !== 3) {
16-
tech.triggerSourceset(el.src || el.currentSrc);
68+
tech.triggerSourceset(src);
69+
return true;
70+
};
71+
72+
/**
73+
* Get the browsers property descriptor for the `innerHTML`
74+
* property. This will allow us to overwrite it without
75+
* destroying native functionality.
76+
*
77+
* @param {HTMLMediaElement} el
78+
* The tech element that should be used to get the descriptor
79+
*
80+
* @return {Object}
81+
* The property descriptor for innerHTML.
82+
*/
83+
const getInnerHTMLDescriptor = (el) => {
84+
const proto = window.Element.prototype;
85+
let innerDescriptor = {};
86+
87+
// preserve getters/setters already on `el.innerHTML` if they exist
88+
if (Object.getOwnPropertyDescriptor(el, 'innerHTML')) {
89+
innerDescriptor = Object.getOwnPropertyDescriptor(el, 'innerHTML');
90+
} else if (Object.getOwnPropertyDescriptor(proto, 'innerHTML')) {
91+
innerDescriptor = Object.getOwnPropertyDescriptor(proto, 'innerHTML');
92+
}
93+
94+
if (!innerDescriptor.get) {
95+
innerDescriptor.get = function() {
96+
return el.cloneNode().innerHTML;
97+
};
98+
}
99+
100+
if (!innerDescriptor.set) {
101+
innerDescriptor.set = function(v) {
102+
// remove all current content from inside
103+
el.innerText = '';
104+
105+
// make a dummy node to use innerHTML on
106+
const dummy = document.createElement(el.nodeName.toLowerCase());
107+
108+
// set innerHTML to the value provided
109+
dummy.innerHTML = v;
110+
111+
// make a document fragment to hold the nodes from dummy
112+
const docFrag = document.createDocumentFragment();
113+
114+
// copy all of the nodes created by the innerHTML on dummy
115+
// to the document fragment
116+
while (dummy.childNodes.length) {
117+
docFrag.appendChild(dummy.childNodes[0]);
118+
}
119+
120+
// now we add all of that html in one by appending the
121+
// document fragment. This is how innerHTML does it.
122+
window.Element.prototype.appendChild.call(el, docFrag);
123+
124+
// then return the result that innerHTML's setter would
125+
return el.innerHTML;
126+
};
17127
}
18128

129+
if (typeof innerDescriptor.enumerable === 'undefined') {
130+
innerDescriptor.enumerable = true;
131+
}
132+
133+
innerDescriptor.configurable = true;
134+
135+
return innerDescriptor;
136+
};
137+
138+
/**
139+
* Get the browsers property descriptor for the `src`
140+
* property. This will allow us to overwrite it without
141+
* destroying native functionality.
142+
*
143+
* @param {HTMLMediaElement} el
144+
* The tech element that should be used to get the descriptor
145+
*
146+
* @return {Object}
147+
* The property descriptor for `src`.
148+
*/
149+
const getSrcDescriptor = (el) => {
19150
const proto = window.HTMLMediaElement.prototype;
20151
let srcDescriptor = {};
21152

@@ -42,42 +173,185 @@ const setupSourceset = function(tech) {
42173
srcDescriptor.enumerable = true;
43174
}
44175

176+
srcDescriptor.configurable = true;
177+
178+
return srcDescriptor;
179+
};
180+
181+
/**
182+
* Patches browser internal functions so that we can tell syncronously
183+
* if a `<source>` was appended to the media element. For some reason this
184+
* causes a `sourceset` if the the media element is ready and has no source.
185+
* This happens when:
186+
* - The page has just loaded and the media element does not have a source.
187+
* - The media element was emptied of all sources, then `load()` was called.
188+
*
189+
* It does this by patching the following functions/properties when they are supported:
190+
*
191+
* - `append()` - can be used to add a `<source>` element to the media element
192+
* - `appendChild()` - can be used to add a `<source>` element to the media element
193+
* - `insertAdjacentHTML()` - can be used to add a `<source>` element to the media element
194+
* - `innerHTML` - can be used to add a `<source>` element to the media element
195+
*
196+
* @param {Html5} tech
197+
* The tech object that sourceset is being setup on.
198+
*/
199+
const firstSourceWatch = function(tech) {
200+
const el = tech.el();
201+
202+
// make sure firstSourceWatch isn't setup twice.
203+
if (el.firstSourceWatch_) {
204+
return;
205+
}
206+
207+
el.firstSourceWatch_ = true;
208+
const oldAppend = el.append;
209+
const oldAppendChild = el.appendChild;
210+
const oldInsertAdjacentHTML = el.insertAdjacentHTML;
211+
const innerDescriptor = getInnerHTMLDescriptor(el);
212+
213+
el.appendChild = function() {
214+
const retval = oldAppendChild.apply(el, arguments);
215+
216+
sourcesetLoad(tech);
217+
218+
return retval;
219+
};
220+
221+
if (oldAppend) {
222+
el.append = function() {
223+
const retval = oldAppend.apply(el, arguments);
224+
225+
sourcesetLoad(tech);
226+
227+
return retval;
228+
};
229+
}
230+
231+
if (oldInsertAdjacentHTML) {
232+
el.insertAdjacentHTML = function() {
233+
const retval = oldInsertAdjacentHTML.apply(el, arguments);
234+
235+
sourcesetLoad(tech);
236+
237+
return retval;
238+
};
239+
}
240+
241+
Object.defineProperty(el, 'innerHTML', {
242+
get: innerDescriptor.get.bind(el),
243+
set(v) {
244+
const retval = innerDescriptor.set.call(el, v);
245+
246+
sourcesetLoad(tech);
247+
248+
return retval;
249+
},
250+
configurable: true,
251+
enumerable: innerDescriptor.enumerable
252+
});
253+
254+
// on the first sourceset, we need to revert
255+
// our changes
256+
tech.one('sourceset', (e) => {
257+
el.firstSourceWatch_ = false;
258+
el.appendChild = oldAppendChild;
259+
260+
if (oldAppend) {
261+
el.append = oldAppend;
262+
}
263+
if (oldInsertAdjacentHTML) {
264+
el.insertAdjacentHTML = oldInsertAdjacentHTML;
265+
}
266+
267+
Object.defineProperty(el, 'innerHTML', innerDescriptor);
268+
});
269+
};
270+
271+
/**
272+
* setup `sourceset` handling on the `Html5` tech. This function
273+
* patches the following element properties/functions:
274+
*
275+
* - `src` - to determine when `src` is set
276+
* - `setAttribute()` - to determine when `src` is set
277+
* - `load()` - this re-triggers the source selection algorithm, and can
278+
* cause a sourceset.
279+
*
280+
* If there is no source when we are adding `sourceset` support or during a `load()`
281+
* we also patch the functions listed in `firstSourceWatch`.
282+
*
283+
* @param {Html5} tech
284+
* The tech to patch
285+
*/
286+
const setupSourceset = function(tech) {
287+
if (!tech.featuresSourceset) {
288+
return;
289+
}
290+
291+
const el = tech.el();
292+
293+
// make sure sourceset isn't setup twice.
294+
if (el.setupSourceset_) {
295+
return;
296+
}
297+
298+
el.setupSourceset_ = true;
299+
300+
const srcDescriptor = getSrcDescriptor(el);
301+
const oldSetAttribute = el.setAttribute;
302+
const oldLoad = el.load;
303+
304+
// we need to fire sourceset when the player is ready
305+
// if we find that the media element had a src when it was
306+
// given to us and that tech element is not in a stalled state
307+
if (el.src || el.currentSrc && el.initNetworkState_ !== 3) {
308+
if (el.currentSrc) {
309+
tech.triggerSourceset(el.currentSrc);
310+
} else {
311+
sourcesetLoad(tech);
312+
}
313+
}
314+
315+
// for some reason adding a source element when a mediaElement has no source
316+
// calls `load` internally right away. We need to handle that.
317+
if (!el.src && !el.currentSrc && !tech.$$('source').length) {
318+
firstSourceWatch(tech);
319+
}
320+
45321
Object.defineProperty(el, 'src', {
46322
get: srcDescriptor.get.bind(el),
47323
set: (v) => {
48324
const retval = srcDescriptor.set.call(el, v);
49325

50-
tech.triggerSourceset(v);
326+
// we use the getter here to get the actual value set on src
327+
tech.triggerSourceset(el.src);
51328

52329
return retval;
53330
},
54331
configurable: true,
55332
enumerable: srcDescriptor.enumerable
56333
});
57334

58-
const oldSetAttribute = el.setAttribute;
59-
60335
el.setAttribute = (n, v) => {
61336
const retval = oldSetAttribute.call(el, n, v);
62337

63338
if (n === 'src') {
64-
tech.triggerSourceset(v);
339+
tech.triggerSourceset(el.getAttribute('src'));
65340
}
66341

67342
return retval;
68343
};
69344

70-
const oldLoad = el.load;
71-
72345
el.load = () => {
73346
const retval = oldLoad.call(el);
74347

75-
// if `el.src` is set, that source will be loaded
76-
// otherwise, we can't know for sure what source will be set because
77-
// source elements will be used but implementing the source selection algorithm
78-
// is laborious and asynchronous, so,
79-
// instead return an empty string to basically indicate source may change
80-
tech.triggerSourceset(el.src || '');
348+
// if load was called, but there was no source to fire
349+
// sourceset on. We have to watch for a source append
350+
// as that can trigger a `sourceset` when the media element
351+
// has no source
352+
if (!sourcesetLoad(tech)) {
353+
firstSourceWatch(tech);
354+
}
81355

82356
return retval;
83357
};

0 commit comments

Comments
 (0)