UNPKG

299 kBJavaScriptView Raw
1typeof navigator === "object" && (function (global, factory) {
2 typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
3 typeof define === 'function' && define.amd ? define('Plyr', factory) :
4 (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Plyr = factory());
5})(this, (function () { 'use strict';
6
7 function _defineProperty$1(obj, key, value) {
8 key = _toPropertyKey(key);
9 if (key in obj) {
10 Object.defineProperty(obj, key, {
11 value: value,
12 enumerable: true,
13 configurable: true,
14 writable: true
15 });
16 } else {
17 obj[key] = value;
18 }
19 return obj;
20 }
21 function _toPrimitive(input, hint) {
22 if (typeof input !== "object" || input === null) return input;
23 var prim = input[Symbol.toPrimitive];
24 if (prim !== undefined) {
25 var res = prim.call(input, hint || "default");
26 if (typeof res !== "object") return res;
27 throw new TypeError("@@toPrimitive must return a primitive value.");
28 }
29 return (hint === "string" ? String : Number)(input);
30 }
31 function _toPropertyKey(arg) {
32 var key = _toPrimitive(arg, "string");
33 return typeof key === "symbol" ? key : String(key);
34 }
35
36 function _classCallCheck(e, t) {
37 if (!(e instanceof t)) throw new TypeError("Cannot call a class as a function");
38 }
39 function _defineProperties(e, t) {
40 for (var n = 0; n < t.length; n++) {
41 var r = t[n];
42 r.enumerable = r.enumerable || !1, r.configurable = !0, "value" in r && (r.writable = !0), Object.defineProperty(e, r.key, r);
43 }
44 }
45 function _createClass(e, t, n) {
46 return t && _defineProperties(e.prototype, t), n && _defineProperties(e, n), e;
47 }
48 function _defineProperty(e, t, n) {
49 return t in e ? Object.defineProperty(e, t, {
50 value: n,
51 enumerable: !0,
52 configurable: !0,
53 writable: !0
54 }) : e[t] = n, e;
55 }
56 function ownKeys(e, t) {
57 var n = Object.keys(e);
58 if (Object.getOwnPropertySymbols) {
59 var r = Object.getOwnPropertySymbols(e);
60 t && (r = r.filter(function (t) {
61 return Object.getOwnPropertyDescriptor(e, t).enumerable;
62 })), n.push.apply(n, r);
63 }
64 return n;
65 }
66 function _objectSpread2(e) {
67 for (var t = 1; t < arguments.length; t++) {
68 var n = null != arguments[t] ? arguments[t] : {};
69 t % 2 ? ownKeys(Object(n), !0).forEach(function (t) {
70 _defineProperty(e, t, n[t]);
71 }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : ownKeys(Object(n)).forEach(function (t) {
72 Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t));
73 });
74 }
75 return e;
76 }
77 var defaults$1 = {
78 addCSS: !0,
79 thumbWidth: 15,
80 watch: !0
81 };
82 function matches$1(e, t) {
83 return function () {
84 return Array.from(document.querySelectorAll(t)).includes(this);
85 }.call(e, t);
86 }
87 function trigger(e, t) {
88 if (e && t) {
89 var n = new Event(t, {
90 bubbles: !0
91 });
92 e.dispatchEvent(n);
93 }
94 }
95 var getConstructor$1 = function (e) {
96 return null != e ? e.constructor : null;
97 },
98 instanceOf$1 = function (e, t) {
99 return !!(e && t && e instanceof t);
100 },
101 isNullOrUndefined$1 = function (e) {
102 return null == e;
103 },
104 isObject$1 = function (e) {
105 return getConstructor$1(e) === Object;
106 },
107 isNumber$1 = function (e) {
108 return getConstructor$1(e) === Number && !Number.isNaN(e);
109 },
110 isString$1 = function (e) {
111 return getConstructor$1(e) === String;
112 },
113 isBoolean$1 = function (e) {
114 return getConstructor$1(e) === Boolean;
115 },
116 isFunction$1 = function (e) {
117 return getConstructor$1(e) === Function;
118 },
119 isArray$1 = function (e) {
120 return Array.isArray(e);
121 },
122 isNodeList$1 = function (e) {
123 return instanceOf$1(e, NodeList);
124 },
125 isElement$1 = function (e) {
126 return instanceOf$1(e, Element);
127 },
128 isEvent$1 = function (e) {
129 return instanceOf$1(e, Event);
130 },
131 isEmpty$1 = function (e) {
132 return isNullOrUndefined$1(e) || (isString$1(e) || isArray$1(e) || isNodeList$1(e)) && !e.length || isObject$1(e) && !Object.keys(e).length;
133 },
134 is$1 = {
135 nullOrUndefined: isNullOrUndefined$1,
136 object: isObject$1,
137 number: isNumber$1,
138 string: isString$1,
139 boolean: isBoolean$1,
140 function: isFunction$1,
141 array: isArray$1,
142 nodeList: isNodeList$1,
143 element: isElement$1,
144 event: isEvent$1,
145 empty: isEmpty$1
146 };
147 function getDecimalPlaces(e) {
148 var t = "".concat(e).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
149 return t ? Math.max(0, (t[1] ? t[1].length : 0) - (t[2] ? +t[2] : 0)) : 0;
150 }
151 function round(e, t) {
152 if (1 > t) {
153 var n = getDecimalPlaces(t);
154 return parseFloat(e.toFixed(n));
155 }
156 return Math.round(e / t) * t;
157 }
158 var RangeTouch = function () {
159 function e(t, n) {
160 _classCallCheck(this, e), is$1.element(t) ? this.element = t : is$1.string(t) && (this.element = document.querySelector(t)), is$1.element(this.element) && is$1.empty(this.element.rangeTouch) && (this.config = _objectSpread2({}, defaults$1, {}, n), this.init());
161 }
162 return _createClass(e, [{
163 key: "init",
164 value: function () {
165 e.enabled && (this.config.addCSS && (this.element.style.userSelect = "none", this.element.style.webKitUserSelect = "none", this.element.style.touchAction = "manipulation"), this.listeners(!0), this.element.rangeTouch = this);
166 }
167 }, {
168 key: "destroy",
169 value: function () {
170 e.enabled && (this.config.addCSS && (this.element.style.userSelect = "", this.element.style.webKitUserSelect = "", this.element.style.touchAction = ""), this.listeners(!1), this.element.rangeTouch = null);
171 }
172 }, {
173 key: "listeners",
174 value: function (e) {
175 var t = this,
176 n = e ? "addEventListener" : "removeEventListener";
177 ["touchstart", "touchmove", "touchend"].forEach(function (e) {
178 t.element[n](e, function (e) {
179 return t.set(e);
180 }, !1);
181 });
182 }
183 }, {
184 key: "get",
185 value: function (t) {
186 if (!e.enabled || !is$1.event(t)) return null;
187 var n,
188 r = t.target,
189 i = t.changedTouches[0],
190 o = parseFloat(r.getAttribute("min")) || 0,
191 s = parseFloat(r.getAttribute("max")) || 100,
192 u = parseFloat(r.getAttribute("step")) || 1,
193 c = r.getBoundingClientRect(),
194 a = 100 / c.width * (this.config.thumbWidth / 2) / 100;
195 return 0 > (n = 100 / c.width * (i.clientX - c.left)) ? n = 0 : 100 < n && (n = 100), 50 > n ? n -= (100 - 2 * n) * a : 50 < n && (n += 2 * (n - 50) * a), o + round(n / 100 * (s - o), u);
196 }
197 }, {
198 key: "set",
199 value: function (t) {
200 e.enabled && is$1.event(t) && !t.target.disabled && (t.preventDefault(), t.target.value = this.get(t), trigger(t.target, "touchend" === t.type ? "change" : "input"));
201 }
202 }], [{
203 key: "setup",
204 value: function (t) {
205 var n = 1 < arguments.length && void 0 !== arguments[1] ? arguments[1] : {},
206 r = null;
207 if (is$1.empty(t) || is$1.string(t) ? r = Array.from(document.querySelectorAll(is$1.string(t) ? t : 'input[type="range"]')) : is$1.element(t) ? r = [t] : is$1.nodeList(t) ? r = Array.from(t) : is$1.array(t) && (r = t.filter(is$1.element)), is$1.empty(r)) return null;
208 var i = _objectSpread2({}, defaults$1, {}, n);
209 if (is$1.string(t) && i.watch) {
210 var o = new MutationObserver(function (n) {
211 Array.from(n).forEach(function (n) {
212 Array.from(n.addedNodes).forEach(function (n) {
213 is$1.element(n) && matches$1(n, t) && new e(n, i);
214 });
215 });
216 });
217 o.observe(document.body, {
218 childList: !0,
219 subtree: !0
220 });
221 }
222 return r.map(function (t) {
223 return new e(t, n);
224 });
225 }
226 }, {
227 key: "enabled",
228 get: function () {
229 return "ontouchstart" in document.documentElement;
230 }
231 }]), e;
232 }();
233
234 // ==========================================================================
235 // Type checking utils
236 // ==========================================================================
237
238 const getConstructor = input => input !== null && typeof input !== 'undefined' ? input.constructor : null;
239 const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor);
240 const isNullOrUndefined = input => input === null || typeof input === 'undefined';
241 const isObject = input => getConstructor(input) === Object;
242 const isNumber = input => getConstructor(input) === Number && !Number.isNaN(input);
243 const isString = input => getConstructor(input) === String;
244 const isBoolean = input => getConstructor(input) === Boolean;
245 const isFunction = input => typeof input === 'function';
246 const isArray = input => Array.isArray(input);
247 const isWeakMap = input => instanceOf(input, WeakMap);
248 const isNodeList = input => instanceOf(input, NodeList);
249 const isTextNode = input => getConstructor(input) === Text;
250 const isEvent = input => instanceOf(input, Event);
251 const isKeyboardEvent = input => instanceOf(input, KeyboardEvent);
252 const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);
253 const isTrack = input => instanceOf(input, TextTrack) || !isNullOrUndefined(input) && isString(input.kind);
254 const isPromise = input => instanceOf(input, Promise) && isFunction(input.then);
255 const isElement = input => input !== null && typeof input === 'object' && input.nodeType === 1 && typeof input.style === 'object' && typeof input.ownerDocument === 'object';
256 const isEmpty = input => isNullOrUndefined(input) || (isString(input) || isArray(input) || isNodeList(input)) && !input.length || isObject(input) && !Object.keys(input).length;
257 const isUrl = input => {
258 // Accept a URL object
259 if (instanceOf(input, window.URL)) {
260 return true;
261 }
262
263 // Must be string from here
264 if (!isString(input)) {
265 return false;
266 }
267
268 // Add the protocol if required
269 let string = input;
270 if (!input.startsWith('http://') || !input.startsWith('https://')) {
271 string = `http://${input}`;
272 }
273 try {
274 return !isEmpty(new URL(string).hostname);
275 } catch (_) {
276 return false;
277 }
278 };
279 var is = {
280 nullOrUndefined: isNullOrUndefined,
281 object: isObject,
282 number: isNumber,
283 string: isString,
284 boolean: isBoolean,
285 function: isFunction,
286 array: isArray,
287 weakMap: isWeakMap,
288 nodeList: isNodeList,
289 element: isElement,
290 textNode: isTextNode,
291 event: isEvent,
292 keyboardEvent: isKeyboardEvent,
293 cue: isCue,
294 track: isTrack,
295 promise: isPromise,
296 url: isUrl,
297 empty: isEmpty
298 };
299
300 // ==========================================================================
301 const transitionEndEvent = (() => {
302 const element = document.createElement('span');
303 const events = {
304 WebkitTransition: 'webkitTransitionEnd',
305 MozTransition: 'transitionend',
306 OTransition: 'oTransitionEnd otransitionend',
307 transition: 'transitionend'
308 };
309 const type = Object.keys(events).find(event => element.style[event] !== undefined);
310 return is.string(type) ? events[type] : false;
311 })();
312
313 // Force repaint of element
314 function repaint(element, delay) {
315 setTimeout(() => {
316 try {
317 // eslint-disable-next-line no-param-reassign
318 element.hidden = true;
319
320 // eslint-disable-next-line no-unused-expressions
321 element.offsetHeight;
322
323 // eslint-disable-next-line no-param-reassign
324 element.hidden = false;
325 } catch (_) {
326 // Do nothing
327 }
328 }, delay);
329 }
330
331 // ==========================================================================
332 // Browser sniffing
333 // Unfortunately, due to mixed support, UA sniffing is required
334 // ==========================================================================
335
336 const isIE = Boolean(window.document.documentMode);
337 const isEdge = /Edge/g.test(navigator.userAgent);
338 const isWebKit = 'WebkitAppearance' in document.documentElement.style && !/Edge/g.test(navigator.userAgent);
339 const isIPhone = /iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;
340 // navigator.platform may be deprecated but this check is still required
341 const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
342 const isIos = /iPad|iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;
343 var browser = {
344 isIE,
345 isEdge,
346 isWebKit,
347 isIPhone,
348 isIPadOS,
349 isIos
350 };
351
352 // ==========================================================================
353
354 // Clone nested objects
355 function cloneDeep(object) {
356 return JSON.parse(JSON.stringify(object));
357 }
358
359 // Get a nested value in an object
360 function getDeep(object, path) {
361 return path.split('.').reduce((obj, key) => obj && obj[key], object);
362 }
363
364 // Deep extend destination object with N more objects
365 function extend(target = {}, ...sources) {
366 if (!sources.length) {
367 return target;
368 }
369 const source = sources.shift();
370 if (!is.object(source)) {
371 return target;
372 }
373 Object.keys(source).forEach(key => {
374 if (is.object(source[key])) {
375 if (!Object.keys(target).includes(key)) {
376 Object.assign(target, {
377 [key]: {}
378 });
379 }
380 extend(target[key], source[key]);
381 } else {
382 Object.assign(target, {
383 [key]: source[key]
384 });
385 }
386 });
387 return extend(target, ...sources);
388 }
389
390 // ==========================================================================
391
392 // Wrap an element
393 function wrap(elements, wrapper) {
394 // Convert `elements` to an array, if necessary.
395 const targets = elements.length ? elements : [elements];
396
397 // Loops backwards to prevent having to clone the wrapper on the
398 // first element (see `child` below).
399 Array.from(targets).reverse().forEach((element, index) => {
400 const child = index > 0 ? wrapper.cloneNode(true) : wrapper;
401 // Cache the current parent and sibling.
402 const parent = element.parentNode;
403 const sibling = element.nextSibling;
404
405 // Wrap the element (is automatically removed from its current
406 // parent).
407 child.appendChild(element);
408
409 // If the element had a sibling, insert the wrapper before
410 // the sibling to maintain the HTML structure; otherwise, just
411 // append it to the parent.
412 if (sibling) {
413 parent.insertBefore(child, sibling);
414 } else {
415 parent.appendChild(child);
416 }
417 });
418 }
419
420 // Set attributes
421 function setAttributes(element, attributes) {
422 if (!is.element(element) || is.empty(attributes)) return;
423
424 // Assume null and undefined attributes should be left out,
425 // Setting them would otherwise convert them to "null" and "undefined"
426 Object.entries(attributes).filter(([, value]) => !is.nullOrUndefined(value)).forEach(([key, value]) => element.setAttribute(key, value));
427 }
428
429 // Create a DocumentFragment
430 function createElement(type, attributes, text) {
431 // Create a new <element>
432 const element = document.createElement(type);
433
434 // Set all passed attributes
435 if (is.object(attributes)) {
436 setAttributes(element, attributes);
437 }
438
439 // Add text node
440 if (is.string(text)) {
441 element.innerText = text;
442 }
443
444 // Return built element
445 return element;
446 }
447
448 // Insert an element after another
449 function insertAfter(element, target) {
450 if (!is.element(element) || !is.element(target)) return;
451 target.parentNode.insertBefore(element, target.nextSibling);
452 }
453
454 // Insert a DocumentFragment
455 function insertElement(type, parent, attributes, text) {
456 if (!is.element(parent)) return;
457 parent.appendChild(createElement(type, attributes, text));
458 }
459
460 // Remove element(s)
461 function removeElement(element) {
462 if (is.nodeList(element) || is.array(element)) {
463 Array.from(element).forEach(removeElement);
464 return;
465 }
466 if (!is.element(element) || !is.element(element.parentNode)) {
467 return;
468 }
469 element.parentNode.removeChild(element);
470 }
471
472 // Remove all child elements
473 function emptyElement(element) {
474 if (!is.element(element)) return;
475 let {
476 length
477 } = element.childNodes;
478 while (length > 0) {
479 element.removeChild(element.lastChild);
480 length -= 1;
481 }
482 }
483
484 // Replace element
485 function replaceElement(newChild, oldChild) {
486 if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) return null;
487 oldChild.parentNode.replaceChild(newChild, oldChild);
488 return newChild;
489 }
490
491 // Get an attribute object from a string selector
492 function getAttributesFromSelector(sel, existingAttributes) {
493 // For example:
494 // '.test' to { class: 'test' }
495 // '#test' to { id: 'test' }
496 // '[data-test="test"]' to { 'data-test': 'test' }
497
498 if (!is.string(sel) || is.empty(sel)) return {};
499 const attributes = {};
500 const existing = extend({}, existingAttributes);
501 sel.split(',').forEach(s => {
502 // Remove whitespace
503 const selector = s.trim();
504 const className = selector.replace('.', '');
505 const stripped = selector.replace(/[[\]]/g, '');
506 // Get the parts and value
507 const parts = stripped.split('=');
508 const [key] = parts;
509 const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : '';
510 // Get the first character
511 const start = selector.charAt(0);
512 switch (start) {
513 case '.':
514 // Add to existing classname
515 if (is.string(existing.class)) {
516 attributes.class = `${existing.class} ${className}`;
517 } else {
518 attributes.class = className;
519 }
520 break;
521 case '#':
522 // ID selector
523 attributes.id = selector.replace('#', '');
524 break;
525 case '[':
526 // Attribute selector
527 attributes[key] = value;
528 break;
529 }
530 });
531 return extend(existing, attributes);
532 }
533
534 // Toggle hidden
535 function toggleHidden(element, hidden) {
536 if (!is.element(element)) return;
537 let hide = hidden;
538 if (!is.boolean(hide)) {
539 hide = !element.hidden;
540 }
541
542 // eslint-disable-next-line no-param-reassign
543 element.hidden = hide;
544 }
545
546 // Mirror Element.classList.toggle, with IE compatibility for "force" argument
547 function toggleClass(element, className, force) {
548 if (is.nodeList(element)) {
549 return Array.from(element).map(e => toggleClass(e, className, force));
550 }
551 if (is.element(element)) {
552 let method = 'toggle';
553 if (typeof force !== 'undefined') {
554 method = force ? 'add' : 'remove';
555 }
556 element.classList[method](className);
557 return element.classList.contains(className);
558 }
559 return false;
560 }
561
562 // Has class name
563 function hasClass(element, className) {
564 return is.element(element) && element.classList.contains(className);
565 }
566
567 // Element matches selector
568 function matches(element, selector) {
569 const {
570 prototype
571 } = Element;
572 function match() {
573 return Array.from(document.querySelectorAll(selector)).includes(this);
574 }
575 const method = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match;
576 return method.call(element, selector);
577 }
578
579 // Closest ancestor element matching selector (also tests element itself)
580 function closest$1(element, selector) {
581 const {
582 prototype
583 } = Element;
584
585 // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
586 function closestElement() {
587 let el = this;
588 do {
589 if (matches.matches(el, selector)) return el;
590 el = el.parentElement || el.parentNode;
591 } while (el !== null && el.nodeType === 1);
592 return null;
593 }
594 const method = prototype.closest || closestElement;
595 return method.call(element, selector);
596 }
597
598 // Find all elements
599 function getElements(selector) {
600 return this.elements.container.querySelectorAll(selector);
601 }
602
603 // Find a single element
604 function getElement(selector) {
605 return this.elements.container.querySelector(selector);
606 }
607
608 // Set focus and tab focus class
609 function setFocus(element = null, focusVisible = false) {
610 if (!is.element(element)) return;
611
612 // Set regular focus
613 element.focus({
614 preventScroll: true,
615 focusVisible
616 });
617 }
618
619 // ==========================================================================
620
621 // Default codecs for checking mimetype support
622 const defaultCodecs = {
623 'audio/ogg': 'vorbis',
624 'audio/wav': '1',
625 'video/webm': 'vp8, vorbis',
626 'video/mp4': 'avc1.42E01E, mp4a.40.2',
627 'video/ogg': 'theora'
628 };
629
630 // Check for feature support
631 const support = {
632 // Basic support
633 audio: 'canPlayType' in document.createElement('audio'),
634 video: 'canPlayType' in document.createElement('video'),
635 // Check for support
636 // Basic functionality vs full UI
637 check(type, provider) {
638 const api = support[type] || provider !== 'html5';
639 const ui = api && support.rangeInput;
640 return {
641 api,
642 ui
643 };
644 },
645 // Picture-in-picture support
646 // Safari & Chrome only currently
647 pip: (() => {
648 // While iPhone's support picture-in-picture for some apps, seemingly Safari isn't one of them
649 // It will throw the following error when trying to enter picture-in-picture
650 // `NotSupportedError: The Picture-in-Picture mode is not supported.`
651 if (browser.isIPhone) {
652 return false;
653 }
654
655 // Safari
656 // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls
657 if (is.function(createElement('video').webkitSetPresentationMode)) {
658 return true;
659 }
660
661 // Chrome
662 // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture
663 if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) {
664 return true;
665 }
666 return false;
667 })(),
668 // Airplay support
669 // Safari only currently
670 airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent),
671 // Inline playback support
672 // https://webkit.org/blog/6784/new-video-policies-for-ios/
673 playsinline: 'playsInline' in document.createElement('video'),
674 // Check for mime type support against a player instance
675 // Credits: http://diveintohtml5.info/everything.html
676 // Related: http://www.leanbackplayer.com/test/h5mt.html
677 mime(input) {
678 if (is.empty(input)) {
679 return false;
680 }
681 const [mediaType] = input.split('/');
682 let type = input;
683
684 // Verify we're using HTML5 and there's no media type mismatch
685 if (!this.isHTML5 || mediaType !== this.type) {
686 return false;
687 }
688
689 // Add codec if required
690 if (Object.keys(defaultCodecs).includes(type)) {
691 type += `; codecs="${defaultCodecs[input]}"`;
692 }
693 try {
694 return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));
695 } catch (_) {
696 return false;
697 }
698 },
699 // Check for textTracks support
700 textTracks: 'textTracks' in document.createElement('video'),
701 // <input type="range"> Sliders
702 rangeInput: (() => {
703 const range = document.createElement('input');
704 range.type = 'range';
705 return range.type === 'range';
706 })(),
707 // Touch
708 // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event
709 touch: 'ontouchstart' in document.documentElement,
710 // Detect transitions support
711 transitions: transitionEndEvent !== false,
712 // Reduced motion iOS & MacOS setting
713 // https://webkit.org/blog/7551/responsive-design-for-motion/
714 reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches
715 };
716
717 // ==========================================================================
718
719 // Check for passive event listener support
720 // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
721 // https://www.youtube.com/watch?v=NPM6172J22g
722 const supportsPassiveListeners = (() => {
723 // Test via a getter in the options object to see if the passive property is accessed
724 let supported = false;
725 try {
726 const options = Object.defineProperty({}, 'passive', {
727 get() {
728 supported = true;
729 return null;
730 }
731 });
732 window.addEventListener('test', null, options);
733 window.removeEventListener('test', null, options);
734 } catch (_) {
735 // Do nothing
736 }
737 return supported;
738 })();
739
740 // Toggle event listener
741 function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) {
742 // Bail if no element, event, or callback
743 if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) {
744 return;
745 }
746
747 // Allow multiple events
748 const events = event.split(' ');
749 // Build options
750 // Default to just the capture boolean for browsers with no passive listener support
751 let options = capture;
752
753 // If passive events listeners are supported
754 if (supportsPassiveListeners) {
755 options = {
756 // Whether the listener can be passive (i.e. default never prevented)
757 passive,
758 // Whether the listener is a capturing listener or not
759 capture
760 };
761 }
762
763 // If a single node is passed, bind the event listener
764 events.forEach(type => {
765 if (this && this.eventListeners && toggle) {
766 // Cache event listener
767 this.eventListeners.push({
768 element,
769 type,
770 callback,
771 options
772 });
773 }
774 element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);
775 });
776 }
777
778 // Bind event handler
779 function on(element, events = '', callback, passive = true, capture = false) {
780 toggleListener.call(this, element, events, callback, true, passive, capture);
781 }
782
783 // Unbind event handler
784 function off(element, events = '', callback, passive = true, capture = false) {
785 toggleListener.call(this, element, events, callback, false, passive, capture);
786 }
787
788 // Bind once-only event handler
789 function once(element, events = '', callback, passive = true, capture = false) {
790 const onceCallback = (...args) => {
791 off(element, events, onceCallback, passive, capture);
792 callback.apply(this, args);
793 };
794 toggleListener.call(this, element, events, onceCallback, true, passive, capture);
795 }
796
797 // Trigger event
798 function triggerEvent(element, type = '', bubbles = false, detail = {}) {
799 // Bail if no element
800 if (!is.element(element) || is.empty(type)) {
801 return;
802 }
803
804 // Create and dispatch the event
805 const event = new CustomEvent(type, {
806 bubbles,
807 detail: {
808 ...detail,
809 plyr: this
810 }
811 });
812
813 // Dispatch the event
814 element.dispatchEvent(event);
815 }
816
817 // Unbind all cached event listeners
818 function unbindListeners() {
819 if (this && this.eventListeners) {
820 this.eventListeners.forEach(item => {
821 const {
822 element,
823 type,
824 callback,
825 options
826 } = item;
827 element.removeEventListener(type, callback, options);
828 });
829 this.eventListeners = [];
830 }
831 }
832
833 // Run method when / if player is ready
834 function ready() {
835 return new Promise(resolve => this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)).then(() => {});
836 }
837
838 /**
839 * Silence a Promise-like object.
840 * This is useful for avoiding non-harmful, but potentially confusing "uncaught
841 * play promise" rejection error messages.
842 * @param {Object} value An object that may or may not be `Promise`-like.
843 */
844 function silencePromise(value) {
845 if (is.promise(value)) {
846 value.then(null, () => {});
847 }
848 }
849
850 // ==========================================================================
851
852 // Remove duplicates in an array
853 function dedupe(array) {
854 if (!is.array(array)) {
855 return array;
856 }
857 return array.filter((item, index) => array.indexOf(item) === index);
858 }
859
860 // Get the closest value in an array
861 function closest(array, value) {
862 if (!is.array(array) || !array.length) {
863 return null;
864 }
865 return array.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
866 }
867
868 // ==========================================================================
869
870 // Check support for a CSS declaration
871 function supportsCSS(declaration) {
872 if (!window || !window.CSS) {
873 return false;
874 }
875 return window.CSS.supports(declaration);
876 }
877
878 // Standard/common aspect ratios
879 const standardRatios = [[1, 1], [4, 3], [3, 4], [5, 4], [4, 5], [3, 2], [2, 3], [16, 10], [10, 16], [16, 9], [9, 16], [21, 9], [9, 21], [32, 9], [9, 32]].reduce((out, [x, y]) => ({
880 ...out,
881 [x / y]: [x, y]
882 }), {});
883
884 // Validate an aspect ratio
885 function validateAspectRatio(input) {
886 if (!is.array(input) && (!is.string(input) || !input.includes(':'))) {
887 return false;
888 }
889 const ratio = is.array(input) ? input : input.split(':');
890 return ratio.map(Number).every(is.number);
891 }
892
893 // Reduce an aspect ratio to it's lowest form
894 function reduceAspectRatio(ratio) {
895 if (!is.array(ratio) || !ratio.every(is.number)) {
896 return null;
897 }
898 const [width, height] = ratio;
899 const getDivider = (w, h) => h === 0 ? w : getDivider(h, w % h);
900 const divider = getDivider(width, height);
901 return [width / divider, height / divider];
902 }
903
904 // Calculate an aspect ratio
905 function getAspectRatio(input) {
906 const parse = ratio => validateAspectRatio(ratio) ? ratio.split(':').map(Number) : null;
907 // Try provided ratio
908 let ratio = parse(input);
909
910 // Get from config
911 if (ratio === null) {
912 ratio = parse(this.config.ratio);
913 }
914
915 // Get from embed
916 if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) {
917 ({
918 ratio
919 } = this.embed);
920 }
921
922 // Get from HTML5 video
923 if (ratio === null && this.isHTML5) {
924 const {
925 videoWidth,
926 videoHeight
927 } = this.media;
928 ratio = [videoWidth, videoHeight];
929 }
930 return reduceAspectRatio(ratio);
931 }
932
933 // Set aspect ratio for responsive container
934 function setAspectRatio(input) {
935 if (!this.isVideo) {
936 return {};
937 }
938 const {
939 wrapper
940 } = this.elements;
941 const ratio = getAspectRatio.call(this, input);
942 if (!is.array(ratio)) {
943 return {};
944 }
945 const [x, y] = reduceAspectRatio(ratio);
946 const useNative = supportsCSS(`aspect-ratio: ${x}/${y}`);
947 const padding = 100 / x * y;
948 if (useNative) {
949 wrapper.style.aspectRatio = `${x}/${y}`;
950 } else {
951 wrapper.style.paddingBottom = `${padding}%`;
952 }
953
954 // For Vimeo we have an extra <div> to hide the standard controls and UI
955 if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) {
956 const height = 100 / this.media.offsetWidth * parseInt(window.getComputedStyle(this.media).paddingBottom, 10);
957 const offset = (height - padding) / (height / 50);
958 if (this.fullscreen.active) {
959 wrapper.style.paddingBottom = null;
960 } else {
961 this.media.style.transform = `translateY(-${offset}%)`;
962 }
963 } else if (this.isHTML5) {
964 wrapper.classList.add(this.config.classNames.videoFixedRatio);
965 }
966 return {
967 padding,
968 ratio
969 };
970 }
971
972 // Round an aspect ratio to closest standard ratio
973 function roundAspectRatio(x, y, tolerance = 0.05) {
974 const ratio = x / y;
975 const closestRatio = closest(Object.keys(standardRatios), ratio);
976
977 // Check match is within tolerance
978 if (Math.abs(closestRatio - ratio) <= tolerance) {
979 return standardRatios[closestRatio];
980 }
981
982 // No match
983 return [x, y];
984 }
985
986 // Get the size of the viewport
987 // https://stackoverflow.com/questions/1248081/how-to-get-the-browser-viewport-dimensions
988 function getViewportSize() {
989 const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
990 const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
991 return [width, height];
992 }
993
994 // ==========================================================================
995 const html5 = {
996 getSources() {
997 if (!this.isHTML5) {
998 return [];
999 }
1000 const sources = Array.from(this.media.querySelectorAll('source'));
1001
1002 // Filter out unsupported sources (if type is specified)
1003 return sources.filter(source => {
1004 const type = source.getAttribute('type');
1005 if (is.empty(type)) {
1006 return true;
1007 }
1008 return support.mime.call(this, type);
1009 });
1010 },
1011 // Get quality levels
1012 getQualityOptions() {
1013 // Whether we're forcing all options (e.g. for streaming)
1014 if (this.config.quality.forced) {
1015 return this.config.quality.options;
1016 }
1017
1018 // Get sizes from <source> elements
1019 return html5.getSources.call(this).map(source => Number(source.getAttribute('size'))).filter(Boolean);
1020 },
1021 setup() {
1022 if (!this.isHTML5) {
1023 return;
1024 }
1025 const player = this;
1026
1027 // Set speed options from config
1028 player.options.speed = player.config.speed.options;
1029
1030 // Set aspect ratio if fixed
1031 if (!is.empty(this.config.ratio)) {
1032 setAspectRatio.call(player);
1033 }
1034
1035 // Quality
1036 Object.defineProperty(player.media, 'quality', {
1037 get() {
1038 // Get sources
1039 const sources = html5.getSources.call(player);
1040 const source = sources.find(s => s.getAttribute('src') === player.source);
1041
1042 // Return size, if match is found
1043 return source && Number(source.getAttribute('size'));
1044 },
1045 set(input) {
1046 if (player.quality === input) {
1047 return;
1048 }
1049
1050 // If we're using an external handler...
1051 if (player.config.quality.forced && is.function(player.config.quality.onChange)) {
1052 player.config.quality.onChange(input);
1053 } else {
1054 // Get sources
1055 const sources = html5.getSources.call(player);
1056 // Get first match for requested size
1057 const source = sources.find(s => Number(s.getAttribute('size')) === input);
1058
1059 // No matching source found
1060 if (!source) {
1061 return;
1062 }
1063
1064 // Get current state
1065 const {
1066 currentTime,
1067 paused,
1068 preload,
1069 readyState,
1070 playbackRate
1071 } = player.media;
1072
1073 // Set new source
1074 player.media.src = source.getAttribute('src');
1075
1076 // Prevent loading if preload="none" and the current source isn't loaded (#1044)
1077 if (preload !== 'none' || readyState) {
1078 // Restore time
1079 player.once('loadedmetadata', () => {
1080 player.speed = playbackRate;
1081 player.currentTime = currentTime;
1082
1083 // Resume playing
1084 if (!paused) {
1085 silencePromise(player.play());
1086 }
1087 });
1088
1089 // Load new source
1090 player.media.load();
1091 }
1092 }
1093
1094 // Trigger change event
1095 triggerEvent.call(player, player.media, 'qualitychange', false, {
1096 quality: input
1097 });
1098 }
1099 });
1100 },
1101 // Cancel current network requests
1102 // See https://github.com/sampotts/plyr/issues/174
1103 cancelRequests() {
1104 if (!this.isHTML5) {
1105 return;
1106 }
1107
1108 // Remove child sources
1109 removeElement(html5.getSources.call(this));
1110
1111 // Set blank video src attribute
1112 // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error
1113 // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection
1114 this.media.setAttribute('src', this.config.blankVideo);
1115
1116 // Load the new empty source
1117 // This will cancel existing requests
1118 // See https://github.com/sampotts/plyr/issues/174
1119 this.media.load();
1120
1121 // Debugging
1122 this.debug.log('Cancelled network requests');
1123 }
1124 };
1125
1126 // ==========================================================================
1127
1128 // Generate a random ID
1129 function generateId(prefix) {
1130 return `${prefix}-${Math.floor(Math.random() * 10000)}`;
1131 }
1132
1133 // Format string
1134 function format(input, ...args) {
1135 if (is.empty(input)) return input;
1136 return input.toString().replace(/{(\d+)}/g, (_, i) => args[i].toString());
1137 }
1138
1139 // Get percentage
1140 function getPercentage(current, max) {
1141 if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
1142 return 0;
1143 }
1144 return (current / max * 100).toFixed(2);
1145 }
1146
1147 // Replace all occurrences of a string in a string
1148 const replaceAll = (input = '', find = '', replace = '') => input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString());
1149
1150 // Convert to title case
1151 const toTitleCase = (input = '') => input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.slice(1).toLowerCase());
1152
1153 // Convert string to pascalCase
1154 function toPascalCase(input = '') {
1155 let string = input.toString();
1156
1157 // Convert kebab case
1158 string = replaceAll(string, '-', ' ');
1159
1160 // Convert snake case
1161 string = replaceAll(string, '_', ' ');
1162
1163 // Convert to title case
1164 string = toTitleCase(string);
1165
1166 // Convert to pascal case
1167 return replaceAll(string, ' ', '');
1168 }
1169
1170 // Convert string to pascalCase
1171 function toCamelCase(input = '') {
1172 let string = input.toString();
1173
1174 // Convert to pascal case
1175 string = toPascalCase(string);
1176
1177 // Convert first character to lowercase
1178 return string.charAt(0).toLowerCase() + string.slice(1);
1179 }
1180
1181 // Remove HTML from a string
1182 function stripHTML(source) {
1183 const fragment = document.createDocumentFragment();
1184 const element = document.createElement('div');
1185 fragment.appendChild(element);
1186 element.innerHTML = source;
1187 return fragment.firstChild.innerText;
1188 }
1189
1190 // Like outerHTML, but also works for DocumentFragment
1191 function getHTML(element) {
1192 const wrapper = document.createElement('div');
1193 wrapper.appendChild(element);
1194 return wrapper.innerHTML;
1195 }
1196
1197 // ==========================================================================
1198
1199 // Skip i18n for abbreviations and brand names
1200 const resources = {
1201 pip: 'PIP',
1202 airplay: 'AirPlay',
1203 html5: 'HTML5',
1204 vimeo: 'Vimeo',
1205 youtube: 'YouTube'
1206 };
1207 const i18n = {
1208 get(key = '', config = {}) {
1209 if (is.empty(key) || is.empty(config)) {
1210 return '';
1211 }
1212 let string = getDeep(config.i18n, key);
1213 if (is.empty(string)) {
1214 if (Object.keys(resources).includes(key)) {
1215 return resources[key];
1216 }
1217 return '';
1218 }
1219 const replace = {
1220 '{seektime}': config.seekTime,
1221 '{title}': config.title
1222 };
1223 Object.entries(replace).forEach(([k, v]) => {
1224 string = replaceAll(string, k, v);
1225 });
1226 return string;
1227 }
1228 };
1229
1230 class Storage {
1231 constructor(player) {
1232 _defineProperty$1(this, "get", key => {
1233 if (!Storage.supported || !this.enabled) {
1234 return null;
1235 }
1236 const store = window.localStorage.getItem(this.key);
1237 if (is.empty(store)) {
1238 return null;
1239 }
1240 const json = JSON.parse(store);
1241 return is.string(key) && key.length ? json[key] : json;
1242 });
1243 _defineProperty$1(this, "set", object => {
1244 // Bail if we don't have localStorage support or it's disabled
1245 if (!Storage.supported || !this.enabled) {
1246 return;
1247 }
1248
1249 // Can only store objectst
1250 if (!is.object(object)) {
1251 return;
1252 }
1253
1254 // Get current storage
1255 let storage = this.get();
1256
1257 // Default to empty object
1258 if (is.empty(storage)) {
1259 storage = {};
1260 }
1261
1262 // Update the working copy of the values
1263 extend(storage, object);
1264
1265 // Update storage
1266 try {
1267 window.localStorage.setItem(this.key, JSON.stringify(storage));
1268 } catch (_) {
1269 // Do nothing
1270 }
1271 });
1272 this.enabled = player.config.storage.enabled;
1273 this.key = player.config.storage.key;
1274 }
1275
1276 // Check for actual support (see if we can use it)
1277 static get supported() {
1278 try {
1279 if (!('localStorage' in window)) {
1280 return false;
1281 }
1282 const test = '___test';
1283
1284 // Try to use it (it might be disabled, e.g. user is in private mode)
1285 // see: https://github.com/sampotts/plyr/issues/131
1286 window.localStorage.setItem(test, test);
1287 window.localStorage.removeItem(test);
1288 return true;
1289 } catch (_) {
1290 return false;
1291 }
1292 }
1293 }
1294
1295 // ==========================================================================
1296 // Fetch wrapper
1297 // Using XHR to avoid issues with older browsers
1298 // ==========================================================================
1299
1300 function fetch(url, responseType = 'text') {
1301 return new Promise((resolve, reject) => {
1302 try {
1303 const request = new XMLHttpRequest();
1304
1305 // Check for CORS support
1306 if (!('withCredentials' in request)) {
1307 return;
1308 }
1309 request.addEventListener('load', () => {
1310 if (responseType === 'text') {
1311 try {
1312 resolve(JSON.parse(request.responseText));
1313 } catch (_) {
1314 resolve(request.responseText);
1315 }
1316 } else {
1317 resolve(request.response);
1318 }
1319 });
1320 request.addEventListener('error', () => {
1321 throw new Error(request.status);
1322 });
1323 request.open('GET', url, true);
1324
1325 // Set the required response type
1326 request.responseType = responseType;
1327 request.send();
1328 } catch (error) {
1329 reject(error);
1330 }
1331 });
1332 }
1333
1334 // ==========================================================================
1335
1336 // Load an external SVG sprite
1337 function loadSprite(url, id) {
1338 if (!is.string(url)) {
1339 return;
1340 }
1341 const prefix = 'cache';
1342 const hasId = is.string(id);
1343 let isCached = false;
1344 const exists = () => document.getElementById(id) !== null;
1345 const update = (container, data) => {
1346 // eslint-disable-next-line no-param-reassign
1347 container.innerHTML = data;
1348
1349 // Check again incase of race condition
1350 if (hasId && exists()) {
1351 return;
1352 }
1353
1354 // Inject the SVG to the body
1355 document.body.insertAdjacentElement('afterbegin', container);
1356 };
1357
1358 // Only load once if ID set
1359 if (!hasId || !exists()) {
1360 const useStorage = Storage.supported;
1361 // Create container
1362 const container = document.createElement('div');
1363 container.setAttribute('hidden', '');
1364 if (hasId) {
1365 container.setAttribute('id', id);
1366 }
1367
1368 // Check in cache
1369 if (useStorage) {
1370 const cached = window.localStorage.getItem(`${prefix}-${id}`);
1371 isCached = cached !== null;
1372 if (isCached) {
1373 const data = JSON.parse(cached);
1374 update(container, data.content);
1375 }
1376 }
1377
1378 // Get the sprite
1379 fetch(url).then(result => {
1380 if (is.empty(result)) {
1381 return;
1382 }
1383 if (useStorage) {
1384 try {
1385 window.localStorage.setItem(`${prefix}-${id}`, JSON.stringify({
1386 content: result
1387 }));
1388 } catch (_) {
1389 // Do nothing
1390 }
1391 }
1392 update(container, result);
1393 }).catch(() => {});
1394 }
1395 }
1396
1397 // ==========================================================================
1398
1399 // Time helpers
1400 const getHours = value => Math.trunc(value / 60 / 60 % 60, 10);
1401 const getMinutes = value => Math.trunc(value / 60 % 60, 10);
1402 const getSeconds = value => Math.trunc(value % 60, 10);
1403
1404 // Format time to UI friendly string
1405 function formatTime(time = 0, displayHours = false, inverted = false) {
1406 // Bail if the value isn't a number
1407 if (!is.number(time)) {
1408 return formatTime(undefined, displayHours, inverted);
1409 }
1410
1411 // Format time component to add leading zero
1412 const format = value => `0${value}`.slice(-2);
1413 // Breakdown to hours, mins, secs
1414 let hours = getHours(time);
1415 const mins = getMinutes(time);
1416 const secs = getSeconds(time);
1417
1418 // Do we need to display hours?
1419 if (displayHours || hours > 0) {
1420 hours = `${hours}:`;
1421 } else {
1422 hours = '';
1423 }
1424
1425 // Render
1426 return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
1427 }
1428
1429 // ==========================================================================
1430
1431 // TODO: Don't export a massive object - break down and create class
1432 const controls = {
1433 // Get icon URL
1434 getIconUrl() {
1435 const url = new URL(this.config.iconUrl, window.location);
1436 const host = window.location.host ? window.location.host : window.top.location.host;
1437 const cors = url.host !== host || browser.isIE && !window.svg4everybody;
1438 return {
1439 url: this.config.iconUrl,
1440 cors
1441 };
1442 },
1443 // Find the UI controls
1444 findElements() {
1445 try {
1446 this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
1447
1448 // Buttons
1449 this.elements.buttons = {
1450 play: getElements.call(this, this.config.selectors.buttons.play),
1451 pause: getElement.call(this, this.config.selectors.buttons.pause),
1452 restart: getElement.call(this, this.config.selectors.buttons.restart),
1453 rewind: getElement.call(this, this.config.selectors.buttons.rewind),
1454 fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),
1455 mute: getElement.call(this, this.config.selectors.buttons.mute),
1456 pip: getElement.call(this, this.config.selectors.buttons.pip),
1457 airplay: getElement.call(this, this.config.selectors.buttons.airplay),
1458 settings: getElement.call(this, this.config.selectors.buttons.settings),
1459 captions: getElement.call(this, this.config.selectors.buttons.captions),
1460 fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen)
1461 };
1462
1463 // Progress
1464 this.elements.progress = getElement.call(this, this.config.selectors.progress);
1465
1466 // Inputs
1467 this.elements.inputs = {
1468 seek: getElement.call(this, this.config.selectors.inputs.seek),
1469 volume: getElement.call(this, this.config.selectors.inputs.volume)
1470 };
1471
1472 // Display
1473 this.elements.display = {
1474 buffer: getElement.call(this, this.config.selectors.display.buffer),
1475 currentTime: getElement.call(this, this.config.selectors.display.currentTime),
1476 duration: getElement.call(this, this.config.selectors.display.duration)
1477 };
1478
1479 // Seek tooltip
1480 if (is.element(this.elements.progress)) {
1481 this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);
1482 }
1483 return true;
1484 } catch (error) {
1485 // Log it
1486 this.debug.warn('It looks like there is a problem with your custom controls HTML', error);
1487
1488 // Restore native video controls
1489 this.toggleNativeControls(true);
1490 return false;
1491 }
1492 },
1493 // Create <svg> icon
1494 createIcon(type, attributes) {
1495 const namespace = 'http://www.w3.org/2000/svg';
1496 const iconUrl = controls.getIconUrl.call(this);
1497 const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;
1498 // Create <svg>
1499 const icon = document.createElementNS(namespace, 'svg');
1500 setAttributes(icon, extend(attributes, {
1501 'aria-hidden': 'true',
1502 focusable: 'false'
1503 }));
1504
1505 // Create the <use> to reference sprite
1506 const use = document.createElementNS(namespace, 'use');
1507 const path = `${iconPath}-${type}`;
1508
1509 // Set `href` attributes
1510 // https://github.com/sampotts/plyr/issues/460
1511 // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
1512 if ('href' in use) {
1513 use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);
1514 }
1515
1516 // Always set the older attribute even though it's "deprecated" (it'll be around for ages)
1517 use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);
1518
1519 // Add <use> to <svg>
1520 icon.appendChild(use);
1521 return icon;
1522 },
1523 // Create hidden text label
1524 createLabel(key, attr = {}) {
1525 const text = i18n.get(key, this.config);
1526 const attributes = {
1527 ...attr,
1528 class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ')
1529 };
1530 return createElement('span', attributes, text);
1531 },
1532 // Create a badge
1533 createBadge(text) {
1534 if (is.empty(text)) {
1535 return null;
1536 }
1537 const badge = createElement('span', {
1538 class: this.config.classNames.menu.value
1539 });
1540 badge.appendChild(createElement('span', {
1541 class: this.config.classNames.menu.badge
1542 }, text));
1543 return badge;
1544 },
1545 // Create a <button>
1546 createButton(buttonType, attr) {
1547 const attributes = extend({}, attr);
1548 let type = toCamelCase(buttonType);
1549 const props = {
1550 element: 'button',
1551 toggle: false,
1552 label: null,
1553 icon: null,
1554 labelPressed: null,
1555 iconPressed: null
1556 };
1557 ['element', 'icon', 'label'].forEach(key => {
1558 if (Object.keys(attributes).includes(key)) {
1559 props[key] = attributes[key];
1560 delete attributes[key];
1561 }
1562 });
1563
1564 // Default to 'button' type to prevent form submission
1565 if (props.element === 'button' && !Object.keys(attributes).includes('type')) {
1566 attributes.type = 'button';
1567 }
1568
1569 // Set class name
1570 if (Object.keys(attributes).includes('class')) {
1571 if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) {
1572 extend(attributes, {
1573 class: `${attributes.class} ${this.config.classNames.control}`
1574 });
1575 }
1576 } else {
1577 attributes.class = this.config.classNames.control;
1578 }
1579
1580 // Large play button
1581 switch (buttonType) {
1582 case 'play':
1583 props.toggle = true;
1584 props.label = 'play';
1585 props.labelPressed = 'pause';
1586 props.icon = 'play';
1587 props.iconPressed = 'pause';
1588 break;
1589 case 'mute':
1590 props.toggle = true;
1591 props.label = 'mute';
1592 props.labelPressed = 'unmute';
1593 props.icon = 'volume';
1594 props.iconPressed = 'muted';
1595 break;
1596 case 'captions':
1597 props.toggle = true;
1598 props.label = 'enableCaptions';
1599 props.labelPressed = 'disableCaptions';
1600 props.icon = 'captions-off';
1601 props.iconPressed = 'captions-on';
1602 break;
1603 case 'fullscreen':
1604 props.toggle = true;
1605 props.label = 'enterFullscreen';
1606 props.labelPressed = 'exitFullscreen';
1607 props.icon = 'enter-fullscreen';
1608 props.iconPressed = 'exit-fullscreen';
1609 break;
1610 case 'play-large':
1611 attributes.class += ` ${this.config.classNames.control}--overlaid`;
1612 type = 'play';
1613 props.label = 'play';
1614 props.icon = 'play';
1615 break;
1616 default:
1617 if (is.empty(props.label)) {
1618 props.label = type;
1619 }
1620 if (is.empty(props.icon)) {
1621 props.icon = buttonType;
1622 }
1623 }
1624 const button = createElement(props.element);
1625
1626 // Setup toggle icon and labels
1627 if (props.toggle) {
1628 // Icon
1629 button.appendChild(controls.createIcon.call(this, props.iconPressed, {
1630 class: 'icon--pressed'
1631 }));
1632 button.appendChild(controls.createIcon.call(this, props.icon, {
1633 class: 'icon--not-pressed'
1634 }));
1635
1636 // Label/Tooltip
1637 button.appendChild(controls.createLabel.call(this, props.labelPressed, {
1638 class: 'label--pressed'
1639 }));
1640 button.appendChild(controls.createLabel.call(this, props.label, {
1641 class: 'label--not-pressed'
1642 }));
1643 } else {
1644 button.appendChild(controls.createIcon.call(this, props.icon));
1645 button.appendChild(controls.createLabel.call(this, props.label));
1646 }
1647
1648 // Merge and set attributes
1649 extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
1650 setAttributes(button, attributes);
1651
1652 // We have multiple play buttons
1653 if (type === 'play') {
1654 if (!is.array(this.elements.buttons[type])) {
1655 this.elements.buttons[type] = [];
1656 }
1657 this.elements.buttons[type].push(button);
1658 } else {
1659 this.elements.buttons[type] = button;
1660 }
1661 return button;
1662 },
1663 // Create an <input type='range'>
1664 createRange(type, attributes) {
1665 // Seek input
1666 const input = createElement('input', extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {
1667 type: 'range',
1668 min: 0,
1669 max: 100,
1670 step: 0.01,
1671 value: 0,
1672 autocomplete: 'off',
1673 // A11y fixes for https://github.com/sampotts/plyr/issues/905
1674 role: 'slider',
1675 'aria-label': i18n.get(type, this.config),
1676 'aria-valuemin': 0,
1677 'aria-valuemax': 100,
1678 'aria-valuenow': 0
1679 }, attributes));
1680 this.elements.inputs[type] = input;
1681
1682 // Set the fill for webkit now
1683 controls.updateRangeFill.call(this, input);
1684
1685 // Improve support on touch devices
1686 RangeTouch.setup(input);
1687 return input;
1688 },
1689 // Create a <progress>
1690 createProgress(type, attributes) {
1691 const progress = createElement('progress', extend(getAttributesFromSelector(this.config.selectors.display[type]), {
1692 min: 0,
1693 max: 100,
1694 value: 0,
1695 role: 'progressbar',
1696 'aria-hidden': true
1697 }, attributes));
1698
1699 // Create the label inside
1700 if (type !== 'volume') {
1701 progress.appendChild(createElement('span', null, '0'));
1702 const suffixKey = {
1703 played: 'played',
1704 buffer: 'buffered'
1705 }[type];
1706 const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
1707 progress.innerText = `% ${suffix.toLowerCase()}`;
1708 }
1709 this.elements.display[type] = progress;
1710 return progress;
1711 },
1712 // Create time display
1713 createTime(type, attrs) {
1714 const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
1715 const container = createElement('div', extend(attributes, {
1716 class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),
1717 'aria-label': i18n.get(type, this.config),
1718 role: 'timer'
1719 }), '00:00');
1720
1721 // Reference for updates
1722 this.elements.display[type] = container;
1723 return container;
1724 },
1725 // Bind keyboard shortcuts for a menu item
1726 // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
1727 // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
1728 bindMenuItemShortcuts(menuItem, type) {
1729 // Navigate through menus via arrow keys and space
1730 on.call(this, menuItem, 'keydown keyup', event => {
1731 // We only care about space and ⬆️ ⬇️️ ➡️
1732 if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowRight'].includes(event.key)) {
1733 return;
1734 }
1735
1736 // Prevent play / seek
1737 event.preventDefault();
1738 event.stopPropagation();
1739
1740 // We're just here to prevent the keydown bubbling
1741 if (event.type === 'keydown') {
1742 return;
1743 }
1744 const isRadioButton = matches(menuItem, '[role="menuitemradio"]');
1745
1746 // Show the respective menu
1747 if (!isRadioButton && [' ', 'ArrowRight'].includes(event.key)) {
1748 controls.showMenuPanel.call(this, type, true);
1749 } else {
1750 let target;
1751 if (event.key !== ' ') {
1752 if (event.key === 'ArrowDown' || isRadioButton && event.key === 'ArrowRight') {
1753 target = menuItem.nextElementSibling;
1754 if (!is.element(target)) {
1755 target = menuItem.parentNode.firstElementChild;
1756 }
1757 } else {
1758 target = menuItem.previousElementSibling;
1759 if (!is.element(target)) {
1760 target = menuItem.parentNode.lastElementChild;
1761 }
1762 }
1763 setFocus.call(this, target, true);
1764 }
1765 }
1766 }, false);
1767
1768 // Enter will fire a `click` event but we still need to manage focus
1769 // So we bind to keyup which fires after and set focus here
1770 on.call(this, menuItem, 'keyup', event => {
1771 if (event.key !== 'Return') return;
1772 controls.focusFirstMenuItem.call(this, null, true);
1773 });
1774 },
1775 // Create a settings menu item
1776 createMenuItem({
1777 value,
1778 list,
1779 type,
1780 title,
1781 badge = null,
1782 checked = false
1783 }) {
1784 const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);
1785 const menuItem = createElement('button', extend(attributes, {
1786 type: 'button',
1787 role: 'menuitemradio',
1788 class: `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),
1789 'aria-checked': checked,
1790 value
1791 }));
1792 const flex = createElement('span');
1793
1794 // We have to set as HTML incase of special characters
1795 flex.innerHTML = title;
1796 if (is.element(badge)) {
1797 flex.appendChild(badge);
1798 }
1799 menuItem.appendChild(flex);
1800
1801 // Replicate radio button behavior
1802 Object.defineProperty(menuItem, 'checked', {
1803 enumerable: true,
1804 get() {
1805 return menuItem.getAttribute('aria-checked') === 'true';
1806 },
1807 set(check) {
1808 // Ensure exclusivity
1809 if (check) {
1810 Array.from(menuItem.parentNode.children).filter(node => matches(node, '[role="menuitemradio"]')).forEach(node => node.setAttribute('aria-checked', 'false'));
1811 }
1812 menuItem.setAttribute('aria-checked', check ? 'true' : 'false');
1813 }
1814 });
1815 this.listeners.bind(menuItem, 'click keyup', event => {
1816 if (is.keyboardEvent(event) && event.key !== ' ') {
1817 return;
1818 }
1819 event.preventDefault();
1820 event.stopPropagation();
1821 menuItem.checked = true;
1822 switch (type) {
1823 case 'language':
1824 this.currentTrack = Number(value);
1825 break;
1826 case 'quality':
1827 this.quality = value;
1828 break;
1829 case 'speed':
1830 this.speed = parseFloat(value);
1831 break;
1832 }
1833 controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));
1834 }, type, false);
1835 controls.bindMenuItemShortcuts.call(this, menuItem, type);
1836 list.appendChild(menuItem);
1837 },
1838 // Format a time for display
1839 formatTime(time = 0, inverted = false) {
1840 // Bail if the value isn't a number
1841 if (!is.number(time)) {
1842 return time;
1843 }
1844
1845 // Always display hours if duration is over an hour
1846 const forceHours = getHours(this.duration) > 0;
1847 return formatTime(time, forceHours, inverted);
1848 },
1849 // Update the displayed time
1850 updateTimeDisplay(target = null, time = 0, inverted = false) {
1851 // Bail if there's no element to display or the value isn't a number
1852 if (!is.element(target) || !is.number(time)) {
1853 return;
1854 }
1855
1856 // eslint-disable-next-line no-param-reassign
1857 target.innerText = controls.formatTime(time, inverted);
1858 },
1859 // Update volume UI and storage
1860 updateVolume() {
1861 if (!this.supported.ui) {
1862 return;
1863 }
1864
1865 // Update range
1866 if (is.element(this.elements.inputs.volume)) {
1867 controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
1868 }
1869
1870 // Update mute state
1871 if (is.element(this.elements.buttons.mute)) {
1872 this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
1873 }
1874 },
1875 // Update seek value and lower fill
1876 setRange(target, value = 0) {
1877 if (!is.element(target)) {
1878 return;
1879 }
1880
1881 // eslint-disable-next-line
1882 target.value = value;
1883
1884 // Webkit range fill
1885 controls.updateRangeFill.call(this, target);
1886 },
1887 // Update <progress> elements
1888 updateProgress(event) {
1889 if (!this.supported.ui || !is.event(event)) {
1890 return;
1891 }
1892 let value = 0;
1893 const setProgress = (target, input) => {
1894 const val = is.number(input) ? input : 0;
1895 const progress = is.element(target) ? target : this.elements.display.buffer;
1896
1897 // Update value and label
1898 if (is.element(progress)) {
1899 progress.value = val;
1900
1901 // Update text label inside
1902 const label = progress.getElementsByTagName('span')[0];
1903 if (is.element(label)) {
1904 label.childNodes[0].nodeValue = val;
1905 }
1906 }
1907 };
1908 if (event) {
1909 switch (event.type) {
1910 // Video playing
1911 case 'timeupdate':
1912 case 'seeking':
1913 case 'seeked':
1914 value = getPercentage(this.currentTime, this.duration);
1915
1916 // Set seek range value only if it's a 'natural' time event
1917 if (event.type === 'timeupdate') {
1918 controls.setRange.call(this, this.elements.inputs.seek, value);
1919 }
1920 break;
1921
1922 // Check buffer status
1923 case 'playing':
1924 case 'progress':
1925 setProgress(this.elements.display.buffer, this.buffered * 100);
1926 break;
1927 }
1928 }
1929 },
1930 // Webkit polyfill for lower fill range
1931 updateRangeFill(target) {
1932 // Get range from event if event passed
1933 const range = is.event(target) ? target.target : target;
1934
1935 // Needs to be a valid <input type='range'>
1936 if (!is.element(range) || range.getAttribute('type') !== 'range') {
1937 return;
1938 }
1939
1940 // Set aria values for https://github.com/sampotts/plyr/issues/905
1941 if (matches(range, this.config.selectors.inputs.seek)) {
1942 range.setAttribute('aria-valuenow', this.currentTime);
1943 const currentTime = controls.formatTime(this.currentTime);
1944 const duration = controls.formatTime(this.duration);
1945 const format = i18n.get('seekLabel', this.config);
1946 range.setAttribute('aria-valuetext', format.replace('{currentTime}', currentTime).replace('{duration}', duration));
1947 } else if (matches(range, this.config.selectors.inputs.volume)) {
1948 const percent = range.value * 100;
1949 range.setAttribute('aria-valuenow', percent);
1950 range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);
1951 } else {
1952 range.setAttribute('aria-valuenow', range.value);
1953 }
1954
1955 // WebKit only
1956 if (!browser.isWebKit && !browser.isIPadOS) {
1957 return;
1958 }
1959
1960 // Set CSS custom property
1961 range.style.setProperty('--value', `${range.value / range.max * 100}%`);
1962 },
1963 // Update hover tooltip for seeking
1964 updateSeekTooltip(event) {
1965 var _this$config$markers, _this$config$markers$;
1966 // Bail if setting not true
1967 if (!this.config.tooltips.seek || !is.element(this.elements.inputs.seek) || !is.element(this.elements.display.seekTooltip) || this.duration === 0) {
1968 return;
1969 }
1970 const tipElement = this.elements.display.seekTooltip;
1971 const visible = `${this.config.classNames.tooltip}--visible`;
1972 const toggle = show => toggleClass(tipElement, visible, show);
1973
1974 // Hide on touch
1975 if (this.touch) {
1976 toggle(false);
1977 return;
1978 }
1979
1980 // Determine percentage, if already visible
1981 let percent = 0;
1982 const clientRect = this.elements.progress.getBoundingClientRect();
1983 if (is.event(event)) {
1984 percent = 100 / clientRect.width * (event.pageX - clientRect.left);
1985 } else if (hasClass(tipElement, visible)) {
1986 percent = parseFloat(tipElement.style.left, 10);
1987 } else {
1988 return;
1989 }
1990
1991 // Set bounds
1992 if (percent < 0) {
1993 percent = 0;
1994 } else if (percent > 100) {
1995 percent = 100;
1996 }
1997 const time = this.duration / 100 * percent;
1998
1999 // Display the time a click would seek to
2000 tipElement.innerText = controls.formatTime(time);
2001
2002 // Get marker point for time
2003 const point = (_this$config$markers = this.config.markers) === null || _this$config$markers === void 0 ? void 0 : (_this$config$markers$ = _this$config$markers.points) === null || _this$config$markers$ === void 0 ? void 0 : _this$config$markers$.find(({
2004 time: t
2005 }) => t === Math.round(time));
2006
2007 // Append the point label to the tooltip
2008 if (point) {
2009 tipElement.insertAdjacentHTML('afterbegin', `${point.label}<br>`);
2010 }
2011
2012 // Set position
2013 tipElement.style.left = `${percent}%`;
2014
2015 // Show/hide the tooltip
2016 // If the event is a moues in/out and percentage is inside bounds
2017 if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
2018 toggle(event.type === 'mouseenter');
2019 }
2020 },
2021 // Handle time change event
2022 timeUpdate(event) {
2023 // Only invert if only one time element is displayed and used for both duration and currentTime
2024 const invert = !is.element(this.elements.display.duration) && this.config.invertTime;
2025
2026 // Duration
2027 controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert);
2028
2029 // Ignore updates while seeking
2030 if (event && event.type === 'timeupdate' && this.media.seeking) {
2031 return;
2032 }
2033
2034 // Playing progress
2035 controls.updateProgress.call(this, event);
2036 },
2037 // Show the duration on metadataloaded or durationchange events
2038 durationUpdate() {
2039 // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false
2040 if (!this.supported.ui || !this.config.invertTime && this.currentTime) {
2041 return;
2042 }
2043
2044 // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.
2045 // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415
2046 // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062
2047 // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338
2048 if (this.duration >= 2 ** 32) {
2049 toggleHidden(this.elements.display.currentTime, true);
2050 toggleHidden(this.elements.progress, true);
2051 return;
2052 }
2053
2054 // Update ARIA values
2055 if (is.element(this.elements.inputs.seek)) {
2056 this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
2057 }
2058
2059 // If there's a spot to display duration
2060 const hasDuration = is.element(this.elements.display.duration);
2061
2062 // If there's only one time display, display duration there
2063 if (!hasDuration && this.config.displayDuration && this.paused) {
2064 controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
2065 }
2066
2067 // If there's a duration element, update content
2068 if (hasDuration) {
2069 controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
2070 }
2071 if (this.config.markers.enabled) {
2072 controls.setMarkers.call(this);
2073 }
2074
2075 // Update the tooltip (if visible)
2076 controls.updateSeekTooltip.call(this);
2077 },
2078 // Hide/show a tab
2079 toggleMenuButton(setting, toggle) {
2080 toggleHidden(this.elements.settings.buttons[setting], !toggle);
2081 },
2082 // Update the selected setting
2083 updateSetting(setting, container, input) {
2084 const pane = this.elements.settings.panels[setting];
2085 let value = null;
2086 let list = container;
2087 if (setting === 'captions') {
2088 value = this.currentTrack;
2089 } else {
2090 value = !is.empty(input) ? input : this[setting];
2091
2092 // Get default
2093 if (is.empty(value)) {
2094 value = this.config[setting].default;
2095 }
2096
2097 // Unsupported value
2098 if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
2099 this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
2100 return;
2101 }
2102
2103 // Disabled value
2104 if (!this.config[setting].options.includes(value)) {
2105 this.debug.warn(`Disabled value of '${value}' for ${setting}`);
2106 return;
2107 }
2108 }
2109
2110 // Get the list if we need to
2111 if (!is.element(list)) {
2112 list = pane && pane.querySelector('[role="menu"]');
2113 }
2114
2115 // If there's no list it means it's not been rendered...
2116 if (!is.element(list)) {
2117 return;
2118 }
2119
2120 // Update the label
2121 const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);
2122 label.innerHTML = controls.getLabel.call(this, setting, value);
2123
2124 // Find the radio option and check it
2125 const target = list && list.querySelector(`[value="${value}"]`);
2126 if (is.element(target)) {
2127 target.checked = true;
2128 }
2129 },
2130 // Translate a value into a nice label
2131 getLabel(setting, value) {
2132 switch (setting) {
2133 case 'speed':
2134 return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
2135 case 'quality':
2136 if (is.number(value)) {
2137 const label = i18n.get(`qualityLabel.${value}`, this.config);
2138 if (!label.length) {
2139 return `${value}p`;
2140 }
2141 return label;
2142 }
2143 return toTitleCase(value);
2144 case 'captions':
2145 return captions.getLabel.call(this);
2146 default:
2147 return null;
2148 }
2149 },
2150 // Set the quality menu
2151 setQualityMenu(options) {
2152 // Menu required
2153 if (!is.element(this.elements.settings.panels.quality)) {
2154 return;
2155 }
2156 const type = 'quality';
2157 const list = this.elements.settings.panels.quality.querySelector('[role="menu"]');
2158
2159 // Set options if passed and filter based on uniqueness and config
2160 if (is.array(options)) {
2161 this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
2162 }
2163
2164 // Toggle the pane and tab
2165 const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
2166 controls.toggleMenuButton.call(this, type, toggle);
2167
2168 // Empty the menu
2169 emptyElement(list);
2170
2171 // Check if we need to toggle the parent
2172 controls.checkMenu.call(this);
2173
2174 // If we're hiding, nothing more to do
2175 if (!toggle) {
2176 return;
2177 }
2178
2179 // Get the badge HTML for HD, 4K etc
2180 const getBadge = quality => {
2181 const label = i18n.get(`qualityBadge.${quality}`, this.config);
2182 if (!label.length) {
2183 return null;
2184 }
2185 return controls.createBadge.call(this, label);
2186 };
2187
2188 // Sort options by the config and then render options
2189 this.options.quality.sort((a, b) => {
2190 const sorting = this.config.quality.options;
2191 return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
2192 }).forEach(quality => {
2193 controls.createMenuItem.call(this, {
2194 value: quality,
2195 list,
2196 type,
2197 title: controls.getLabel.call(this, 'quality', quality),
2198 badge: getBadge(quality)
2199 });
2200 });
2201 controls.updateSetting.call(this, type, list);
2202 },
2203 // Set the looping options
2204 /* setLoopMenu() {
2205 // Menu required
2206 if (!is.element(this.elements.settings.panels.loop)) {
2207 return;
2208 }
2209 const options = ['start', 'end', 'all', 'reset'];
2210 const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
2211 // Show the pane and tab
2212 toggleHidden(this.elements.settings.buttons.loop, false);
2213 toggleHidden(this.elements.settings.panels.loop, false);
2214 // Toggle the pane and tab
2215 const toggle = !is.empty(this.loop.options);
2216 controls.toggleMenuButton.call(this, 'loop', toggle);
2217 // Empty the menu
2218 emptyElement(list);
2219 options.forEach(option => {
2220 const item = createElement('li');
2221 const button = createElement(
2222 'button',
2223 extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
2224 type: 'button',
2225 class: this.config.classNames.control,
2226 'data-plyr-loop-action': option,
2227 }),
2228 i18n.get(option, this.config)
2229 );
2230 if (['start', 'end'].includes(option)) {
2231 const badge = controls.createBadge.call(this, '00:00');
2232 button.appendChild(badge);
2233 }
2234 item.appendChild(button);
2235 list.appendChild(item);
2236 });
2237 }, */
2238
2239 // Get current selected caption language
2240 // TODO: rework this to user the getter in the API?
2241
2242 // Set a list of available captions languages
2243 setCaptionsMenu() {
2244 // Menu required
2245 if (!is.element(this.elements.settings.panels.captions)) {
2246 return;
2247 }
2248
2249 // TODO: Captions or language? Currently it's mixed
2250 const type = 'captions';
2251 const list = this.elements.settings.panels.captions.querySelector('[role="menu"]');
2252 const tracks = captions.getTracks.call(this);
2253 const toggle = Boolean(tracks.length);
2254
2255 // Toggle the pane and tab
2256 controls.toggleMenuButton.call(this, type, toggle);
2257
2258 // Empty the menu
2259 emptyElement(list);
2260
2261 // Check if we need to toggle the parent
2262 controls.checkMenu.call(this);
2263
2264 // If there's no captions, bail
2265 if (!toggle) {
2266 return;
2267 }
2268
2269 // Generate options data
2270 const options = tracks.map((track, value) => ({
2271 value,
2272 checked: this.captions.toggled && this.currentTrack === value,
2273 title: captions.getLabel.call(this, track),
2274 badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),
2275 list,
2276 type: 'language'
2277 }));
2278
2279 // Add the "Disabled" option to turn off captions
2280 options.unshift({
2281 value: -1,
2282 checked: !this.captions.toggled,
2283 title: i18n.get('disabled', this.config),
2284 list,
2285 type: 'language'
2286 });
2287
2288 // Generate options
2289 options.forEach(controls.createMenuItem.bind(this));
2290 controls.updateSetting.call(this, type, list);
2291 },
2292 // Set a list of available captions languages
2293 setSpeedMenu() {
2294 // Menu required
2295 if (!is.element(this.elements.settings.panels.speed)) {
2296 return;
2297 }
2298 const type = 'speed';
2299 const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
2300
2301 // Filter out invalid speeds
2302 this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed);
2303
2304 // Toggle the pane and tab
2305 const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
2306 controls.toggleMenuButton.call(this, type, toggle);
2307
2308 // Empty the menu
2309 emptyElement(list);
2310
2311 // Check if we need to toggle the parent
2312 controls.checkMenu.call(this);
2313
2314 // If we're hiding, nothing more to do
2315 if (!toggle) {
2316 return;
2317 }
2318
2319 // Create items
2320 this.options.speed.forEach(speed => {
2321 controls.createMenuItem.call(this, {
2322 value: speed,
2323 list,
2324 type,
2325 title: controls.getLabel.call(this, 'speed', speed)
2326 });
2327 });
2328 controls.updateSetting.call(this, type, list);
2329 },
2330 // Check if we need to hide/show the settings menu
2331 checkMenu() {
2332 const {
2333 buttons
2334 } = this.elements.settings;
2335 const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
2336 toggleHidden(this.elements.settings.menu, !visible);
2337 },
2338 // Focus the first menu item in a given (or visible) menu
2339 focusFirstMenuItem(pane, focusVisible = false) {
2340 if (this.elements.settings.popup.hidden) {
2341 return;
2342 }
2343 let target = pane;
2344 if (!is.element(target)) {
2345 target = Object.values(this.elements.settings.panels).find(p => !p.hidden);
2346 }
2347 const firstItem = target.querySelector('[role^="menuitem"]');
2348 setFocus.call(this, firstItem, focusVisible);
2349 },
2350 // Show/hide menu
2351 toggleMenu(input) {
2352 const {
2353 popup
2354 } = this.elements.settings;
2355 const button = this.elements.buttons.settings;
2356
2357 // Menu and button are required
2358 if (!is.element(popup) || !is.element(button)) {
2359 return;
2360 }
2361
2362 // True toggle by default
2363 const {
2364 hidden
2365 } = popup;
2366 let show = hidden;
2367 if (is.boolean(input)) {
2368 show = input;
2369 } else if (is.keyboardEvent(input) && input.key === 'Escape') {
2370 show = false;
2371 } else if (is.event(input)) {
2372 // If Plyr is in a shadowDOM, the event target is set to the component, instead of the
2373 // Element in the shadowDOM. The path, if available, is complete.
2374 const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;
2375 const isMenuItem = popup.contains(target);
2376
2377 // If the click was inside the menu or if the click
2378 // wasn't the button or menu item and we're trying to
2379 // show the menu (a doc click shouldn't show the menu)
2380 if (isMenuItem || !isMenuItem && input.target !== button && show) {
2381 return;
2382 }
2383 }
2384
2385 // Set button attributes
2386 button.setAttribute('aria-expanded', show);
2387
2388 // Show the actual popup
2389 toggleHidden(popup, !show);
2390
2391 // Add class hook
2392 toggleClass(this.elements.container, this.config.classNames.menu.open, show);
2393
2394 // Focus the first item if key interaction
2395 if (show && is.keyboardEvent(input)) {
2396 controls.focusFirstMenuItem.call(this, null, true);
2397 } else if (!show && !hidden) {
2398 // If closing, re-focus the button
2399 setFocus.call(this, button, is.keyboardEvent(input));
2400 }
2401 },
2402 // Get the natural size of a menu panel
2403 getMenuSize(tab) {
2404 const clone = tab.cloneNode(true);
2405 clone.style.position = 'absolute';
2406 clone.style.opacity = 0;
2407 clone.removeAttribute('hidden');
2408
2409 // Append to parent so we get the "real" size
2410 tab.parentNode.appendChild(clone);
2411
2412 // Get the sizes before we remove
2413 const width = clone.scrollWidth;
2414 const height = clone.scrollHeight;
2415
2416 // Remove from the DOM
2417 removeElement(clone);
2418 return {
2419 width,
2420 height
2421 };
2422 },
2423 // Show a panel in the menu
2424 showMenuPanel(type = '', focusVisible = false) {
2425 const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);
2426
2427 // Nothing to show, bail
2428 if (!is.element(target)) {
2429 return;
2430 }
2431
2432 // Hide all other panels
2433 const container = target.parentNode;
2434 const current = Array.from(container.children).find(node => !node.hidden);
2435
2436 // If we can do fancy animations, we'll animate the height/width
2437 if (support.transitions && !support.reducedMotion) {
2438 // Set the current width as a base
2439 container.style.width = `${current.scrollWidth}px`;
2440 container.style.height = `${current.scrollHeight}px`;
2441
2442 // Get potential sizes
2443 const size = controls.getMenuSize.call(this, target);
2444
2445 // Restore auto height/width
2446 const restore = event => {
2447 // We're only bothered about height and width on the container
2448 if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
2449 return;
2450 }
2451
2452 // Revert back to auto
2453 container.style.width = '';
2454 container.style.height = '';
2455
2456 // Only listen once
2457 off.call(this, container, transitionEndEvent, restore);
2458 };
2459
2460 // Listen for the transition finishing and restore auto height/width
2461 on.call(this, container, transitionEndEvent, restore);
2462
2463 // Set dimensions to target
2464 container.style.width = `${size.width}px`;
2465 container.style.height = `${size.height}px`;
2466 }
2467
2468 // Set attributes on current tab
2469 toggleHidden(current, true);
2470
2471 // Set attributes on target
2472 toggleHidden(target, false);
2473
2474 // Focus the first item
2475 controls.focusFirstMenuItem.call(this, target, focusVisible);
2476 },
2477 // Set the download URL
2478 setDownloadUrl() {
2479 const button = this.elements.buttons.download;
2480
2481 // Bail if no button
2482 if (!is.element(button)) {
2483 return;
2484 }
2485
2486 // Set attribute
2487 button.setAttribute('href', this.download);
2488 },
2489 // Build the default HTML
2490 create(data) {
2491 const {
2492 bindMenuItemShortcuts,
2493 createButton,
2494 createProgress,
2495 createRange,
2496 createTime,
2497 setQualityMenu,
2498 setSpeedMenu,
2499 showMenuPanel
2500 } = controls;
2501 this.elements.controls = null;
2502
2503 // Larger overlaid play button
2504 if (is.array(this.config.controls) && this.config.controls.includes('play-large')) {
2505 this.elements.container.appendChild(createButton.call(this, 'play-large'));
2506 }
2507
2508 // Create the container
2509 const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
2510 this.elements.controls = container;
2511
2512 // Default item attributes
2513 const defaultAttributes = {
2514 class: 'plyr__controls__item'
2515 };
2516
2517 // Loop through controls in order
2518 dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach(control => {
2519 // Restart button
2520 if (control === 'restart') {
2521 container.appendChild(createButton.call(this, 'restart', defaultAttributes));
2522 }
2523
2524 // Rewind button
2525 if (control === 'rewind') {
2526 container.appendChild(createButton.call(this, 'rewind', defaultAttributes));
2527 }
2528
2529 // Play/Pause button
2530 if (control === 'play') {
2531 container.appendChild(createButton.call(this, 'play', defaultAttributes));
2532 }
2533
2534 // Fast forward button
2535 if (control === 'fast-forward') {
2536 container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));
2537 }
2538
2539 // Progress
2540 if (control === 'progress') {
2541 const progressContainer = createElement('div', {
2542 class: `${defaultAttributes.class} plyr__progress__container`
2543 });
2544 const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
2545
2546 // Seek range slider
2547 progress.appendChild(createRange.call(this, 'seek', {
2548 id: `plyr-seek-${data.id}`
2549 }));
2550
2551 // Buffer progress
2552 progress.appendChild(createProgress.call(this, 'buffer'));
2553
2554 // TODO: Add loop display indicator
2555
2556 // Seek tooltip
2557 if (this.config.tooltips.seek) {
2558 const tooltip = createElement('span', {
2559 class: this.config.classNames.tooltip
2560 }, '00:00');
2561 progress.appendChild(tooltip);
2562 this.elements.display.seekTooltip = tooltip;
2563 }
2564 this.elements.progress = progress;
2565 progressContainer.appendChild(this.elements.progress);
2566 container.appendChild(progressContainer);
2567 }
2568
2569 // Media current time display
2570 if (control === 'current-time') {
2571 container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
2572 }
2573
2574 // Media duration display
2575 if (control === 'duration') {
2576 container.appendChild(createTime.call(this, 'duration', defaultAttributes));
2577 }
2578
2579 // Volume controls
2580 if (control === 'mute' || control === 'volume') {
2581 let {
2582 volume
2583 } = this.elements;
2584
2585 // Create the volume container if needed
2586 if (!is.element(volume) || !container.contains(volume)) {
2587 volume = createElement('div', extend({}, defaultAttributes, {
2588 class: `${defaultAttributes.class} plyr__volume`.trim()
2589 }));
2590 this.elements.volume = volume;
2591 container.appendChild(volume);
2592 }
2593
2594 // Toggle mute button
2595 if (control === 'mute') {
2596 volume.appendChild(createButton.call(this, 'mute'));
2597 }
2598
2599 // Volume range control
2600 // Ignored on iOS as it's handled globally
2601 // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
2602 if (control === 'volume' && !browser.isIos && !browser.isIPadOS) {
2603 // Set the attributes
2604 const attributes = {
2605 max: 1,
2606 step: 0.05,
2607 value: this.config.volume
2608 };
2609
2610 // Create the volume range slider
2611 volume.appendChild(createRange.call(this, 'volume', extend(attributes, {
2612 id: `plyr-volume-${data.id}`
2613 })));
2614 }
2615 }
2616
2617 // Toggle captions button
2618 if (control === 'captions') {
2619 container.appendChild(createButton.call(this, 'captions', defaultAttributes));
2620 }
2621
2622 // Settings button / menu
2623 if (control === 'settings' && !is.empty(this.config.settings)) {
2624 const wrapper = createElement('div', extend({}, defaultAttributes, {
2625 class: `${defaultAttributes.class} plyr__menu`.trim(),
2626 hidden: ''
2627 }));
2628 wrapper.appendChild(createButton.call(this, 'settings', {
2629 'aria-haspopup': true,
2630 'aria-controls': `plyr-settings-${data.id}`,
2631 'aria-expanded': false
2632 }));
2633 const popup = createElement('div', {
2634 class: 'plyr__menu__container',
2635 id: `plyr-settings-${data.id}`,
2636 hidden: ''
2637 });
2638 const inner = createElement('div');
2639 const home = createElement('div', {
2640 id: `plyr-settings-${data.id}-home`
2641 });
2642
2643 // Create the menu
2644 const menu = createElement('div', {
2645 role: 'menu'
2646 });
2647 home.appendChild(menu);
2648 inner.appendChild(home);
2649 this.elements.settings.panels.home = home;
2650
2651 // Build the menu items
2652 this.config.settings.forEach(type => {
2653 // TODO: bundle this with the createMenuItem helper and bindings
2654 const menuItem = createElement('button', extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
2655 type: 'button',
2656 class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
2657 role: 'menuitem',
2658 'aria-haspopup': true,
2659 hidden: ''
2660 }));
2661
2662 // Bind menu shortcuts for keyboard users
2663 bindMenuItemShortcuts.call(this, menuItem, type);
2664
2665 // Show menu on click
2666 on.call(this, menuItem, 'click', () => {
2667 showMenuPanel.call(this, type, false);
2668 });
2669 const flex = createElement('span', null, i18n.get(type, this.config));
2670 const value = createElement('span', {
2671 class: this.config.classNames.menu.value
2672 });
2673
2674 // Speed contains HTML entities
2675 value.innerHTML = data[type];
2676 flex.appendChild(value);
2677 menuItem.appendChild(flex);
2678 menu.appendChild(menuItem);
2679
2680 // Build the panes
2681 const pane = createElement('div', {
2682 id: `plyr-settings-${data.id}-${type}`,
2683 hidden: ''
2684 });
2685
2686 // Back button
2687 const backButton = createElement('button', {
2688 type: 'button',
2689 class: `${this.config.classNames.control} ${this.config.classNames.control}--back`
2690 });
2691
2692 // Visible label
2693 backButton.appendChild(createElement('span', {
2694 'aria-hidden': true
2695 }, i18n.get(type, this.config)));
2696
2697 // Screen reader label
2698 backButton.appendChild(createElement('span', {
2699 class: this.config.classNames.hidden
2700 }, i18n.get('menuBack', this.config)));
2701
2702 // Go back via keyboard
2703 on.call(this, pane, 'keydown', event => {
2704 if (event.key !== 'ArrowLeft') return;
2705
2706 // Prevent seek
2707 event.preventDefault();
2708 event.stopPropagation();
2709
2710 // Show the respective menu
2711 showMenuPanel.call(this, 'home', true);
2712 }, false);
2713
2714 // Go back via button click
2715 on.call(this, backButton, 'click', () => {
2716 showMenuPanel.call(this, 'home', false);
2717 });
2718
2719 // Add to pane
2720 pane.appendChild(backButton);
2721
2722 // Menu
2723 pane.appendChild(createElement('div', {
2724 role: 'menu'
2725 }));
2726 inner.appendChild(pane);
2727 this.elements.settings.buttons[type] = menuItem;
2728 this.elements.settings.panels[type] = pane;
2729 });
2730 popup.appendChild(inner);
2731 wrapper.appendChild(popup);
2732 container.appendChild(wrapper);
2733 this.elements.settings.popup = popup;
2734 this.elements.settings.menu = wrapper;
2735 }
2736
2737 // Picture in picture button
2738 if (control === 'pip' && support.pip) {
2739 container.appendChild(createButton.call(this, 'pip', defaultAttributes));
2740 }
2741
2742 // Airplay button
2743 if (control === 'airplay' && support.airplay) {
2744 container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
2745 }
2746
2747 // Download button
2748 if (control === 'download') {
2749 const attributes = extend({}, defaultAttributes, {
2750 element: 'a',
2751 href: this.download,
2752 target: '_blank'
2753 });
2754
2755 // Set download attribute for HTML5 only
2756 if (this.isHTML5) {
2757 attributes.download = '';
2758 }
2759 const {
2760 download
2761 } = this.config.urls;
2762 if (!is.url(download) && this.isEmbed) {
2763 extend(attributes, {
2764 icon: `logo-${this.provider}`,
2765 label: this.provider
2766 });
2767 }
2768 container.appendChild(createButton.call(this, 'download', attributes));
2769 }
2770
2771 // Toggle fullscreen button
2772 if (control === 'fullscreen') {
2773 container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));
2774 }
2775 });
2776
2777 // Set available quality levels
2778 if (this.isHTML5) {
2779 setQualityMenu.call(this, html5.getQualityOptions.call(this));
2780 }
2781 setSpeedMenu.call(this);
2782 return container;
2783 },
2784 // Insert controls
2785 inject() {
2786 // Sprite
2787 if (this.config.loadSprite) {
2788 const icon = controls.getIconUrl.call(this);
2789
2790 // Only load external sprite using AJAX
2791 if (icon.cors) {
2792 loadSprite(icon.url, 'sprite-plyr');
2793 }
2794 }
2795
2796 // Create a unique ID
2797 this.id = Math.floor(Math.random() * 10000);
2798
2799 // Null by default
2800 let container = null;
2801 this.elements.controls = null;
2802
2803 // Set template properties
2804 const props = {
2805 id: this.id,
2806 seektime: this.config.seekTime,
2807 title: this.config.title
2808 };
2809 let update = true;
2810
2811 // If function, run it and use output
2812 if (is.function(this.config.controls)) {
2813 this.config.controls = this.config.controls.call(this, props);
2814 }
2815
2816 // Convert falsy controls to empty array (primarily for empty strings)
2817 if (!this.config.controls) {
2818 this.config.controls = [];
2819 }
2820 if (is.element(this.config.controls) || is.string(this.config.controls)) {
2821 // HTMLElement or Non-empty string passed as the option
2822 container = this.config.controls;
2823 } else {
2824 // Create controls
2825 container = controls.create.call(this, {
2826 id: this.id,
2827 seektime: this.config.seekTime,
2828 speed: this.speed,
2829 quality: this.quality,
2830 captions: captions.getLabel.call(this)
2831 // TODO: Looping
2832 // loop: 'None',
2833 });
2834
2835 update = false;
2836 }
2837
2838 // Replace props with their value
2839 const replace = input => {
2840 let result = input;
2841 Object.entries(props).forEach(([key, value]) => {
2842 result = replaceAll(result, `{${key}}`, value);
2843 });
2844 return result;
2845 };
2846
2847 // Update markup
2848 if (update) {
2849 if (is.string(this.config.controls)) {
2850 container = replace(container);
2851 }
2852 }
2853
2854 // Controls container
2855 let target;
2856
2857 // Inject to custom location
2858 if (is.string(this.config.selectors.controls.container)) {
2859 target = document.querySelector(this.config.selectors.controls.container);
2860 }
2861
2862 // Inject into the container by default
2863 if (!is.element(target)) {
2864 target = this.elements.container;
2865 }
2866
2867 // Inject controls HTML (needs to be before captions, hence "afterbegin")
2868 const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';
2869 target[insertMethod]('afterbegin', container);
2870
2871 // Find the elements if need be
2872 if (!is.element(this.elements.controls)) {
2873 controls.findElements.call(this);
2874 }
2875
2876 // Add pressed property to buttons
2877 if (!is.empty(this.elements.buttons)) {
2878 const addProperty = button => {
2879 const className = this.config.classNames.controlPressed;
2880 button.setAttribute('aria-pressed', 'false');
2881 Object.defineProperty(button, 'pressed', {
2882 configurable: true,
2883 enumerable: true,
2884 get() {
2885 return hasClass(button, className);
2886 },
2887 set(pressed = false) {
2888 toggleClass(button, className, pressed);
2889 button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
2890 }
2891 });
2892 };
2893
2894 // Toggle classname when pressed property is set
2895 Object.values(this.elements.buttons).filter(Boolean).forEach(button => {
2896 if (is.array(button) || is.nodeList(button)) {
2897 Array.from(button).filter(Boolean).forEach(addProperty);
2898 } else {
2899 addProperty(button);
2900 }
2901 });
2902 }
2903
2904 // Edge sometimes doesn't finish the paint so force a repaint
2905 if (browser.isEdge) {
2906 repaint(target);
2907 }
2908
2909 // Setup tooltips
2910 if (this.config.tooltips.controls) {
2911 const {
2912 classNames,
2913 selectors
2914 } = this.config;
2915 const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
2916 const labels = getElements.call(this, selector);
2917 Array.from(labels).forEach(label => {
2918 toggleClass(label, this.config.classNames.hidden, false);
2919 toggleClass(label, this.config.classNames.tooltip, true);
2920 });
2921 }
2922 },
2923 // Set media metadata
2924 setMediaMetadata() {
2925 try {
2926 if ('mediaSession' in navigator) {
2927 navigator.mediaSession.metadata = new window.MediaMetadata({
2928 title: this.config.mediaMetadata.title,
2929 artist: this.config.mediaMetadata.artist,
2930 album: this.config.mediaMetadata.album,
2931 artwork: this.config.mediaMetadata.artwork
2932 });
2933 }
2934 } catch (_) {
2935 // Do nothing
2936 }
2937 },
2938 // Add markers
2939 setMarkers() {
2940 var _this$config$markers2, _this$config$markers3;
2941 if (!this.duration || this.elements.markers) return;
2942
2943 // Get valid points
2944 const points = (_this$config$markers2 = this.config.markers) === null || _this$config$markers2 === void 0 ? void 0 : (_this$config$markers3 = _this$config$markers2.points) === null || _this$config$markers3 === void 0 ? void 0 : _this$config$markers3.filter(({
2945 time
2946 }) => time > 0 && time < this.duration);
2947 if (!(points !== null && points !== void 0 && points.length)) return;
2948 const containerFragment = document.createDocumentFragment();
2949 const pointsFragment = document.createDocumentFragment();
2950 let tipElement = null;
2951 const tipVisible = `${this.config.classNames.tooltip}--visible`;
2952 const toggleTip = show => toggleClass(tipElement, tipVisible, show);
2953
2954 // Inject markers to progress container
2955 points.forEach(point => {
2956 const markerElement = createElement('span', {
2957 class: this.config.classNames.marker
2958 }, '');
2959 const left = `${point.time / this.duration * 100}%`;
2960 if (tipElement) {
2961 // Show on hover
2962 markerElement.addEventListener('mouseenter', () => {
2963 if (point.label) return;
2964 tipElement.style.left = left;
2965 tipElement.innerHTML = point.label;
2966 toggleTip(true);
2967 });
2968
2969 // Hide on leave
2970 markerElement.addEventListener('mouseleave', () => {
2971 toggleTip(false);
2972 });
2973 }
2974 markerElement.addEventListener('click', () => {
2975 this.currentTime = point.time;
2976 });
2977 markerElement.style.left = left;
2978 pointsFragment.appendChild(markerElement);
2979 });
2980 containerFragment.appendChild(pointsFragment);
2981
2982 // Inject a tooltip if needed
2983 if (!this.config.tooltips.seek) {
2984 tipElement = createElement('span', {
2985 class: this.config.classNames.tooltip
2986 }, '');
2987 containerFragment.appendChild(tipElement);
2988 }
2989 this.elements.markers = {
2990 points: pointsFragment,
2991 tip: tipElement
2992 };
2993 this.elements.progress.appendChild(containerFragment);
2994 }
2995 };
2996
2997 // ==========================================================================
2998
2999 /**
3000 * Parse a string to a URL object
3001 * @param {String} input - the URL to be parsed
3002 * @param {Boolean} safe - failsafe parsing
3003 */
3004 function parseUrl(input, safe = true) {
3005 let url = input;
3006 if (safe) {
3007 const parser = document.createElement('a');
3008 parser.href = url;
3009 url = parser.href;
3010 }
3011 try {
3012 return new URL(url);
3013 } catch (_) {
3014 return null;
3015 }
3016 }
3017
3018 // Convert object to URLSearchParams
3019 function buildUrlParams(input) {
3020 const params = new URLSearchParams();
3021 if (is.object(input)) {
3022 Object.entries(input).forEach(([key, value]) => {
3023 params.set(key, value);
3024 });
3025 }
3026 return params;
3027 }
3028
3029 // ==========================================================================
3030 const captions = {
3031 // Setup captions
3032 setup() {
3033 // Requires UI support
3034 if (!this.supported.ui) {
3035 return;
3036 }
3037
3038 // Only Vimeo and HTML5 video supported at this point
3039 if (!this.isVideo || this.isYouTube || this.isHTML5 && !support.textTracks) {
3040 // Clear menu and hide
3041 if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
3042 controls.setCaptionsMenu.call(this);
3043 }
3044 return;
3045 }
3046
3047 // Inject the container
3048 if (!is.element(this.elements.captions)) {
3049 this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));
3050 this.elements.captions.setAttribute('dir', 'auto');
3051 insertAfter(this.elements.captions, this.elements.wrapper);
3052 }
3053
3054 // Fix IE captions if CORS is used
3055 // Fetch captions and inject as blobs instead (data URIs not supported!)
3056 if (browser.isIE && window.URL) {
3057 const elements = this.media.querySelectorAll('track');
3058 Array.from(elements).forEach(track => {
3059 const src = track.getAttribute('src');
3060 const url = parseUrl(src);
3061 if (url !== null && url.hostname !== window.location.href.hostname && ['http:', 'https:'].includes(url.protocol)) {
3062 fetch(src, 'blob').then(blob => {
3063 track.setAttribute('src', window.URL.createObjectURL(blob));
3064 }).catch(() => {
3065 removeElement(track);
3066 });
3067 }
3068 });
3069 }
3070
3071 // Get and set initial data
3072 // The "preferred" options are not realized unless / until the wanted language has a match
3073 // * languages: Array of user's browser languages.
3074 // * language: The language preferred by user settings or config
3075 // * active: The state preferred by user settings or config
3076 // * toggled: The real captions state
3077
3078 const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
3079 const languages = dedupe(browserLanguages.map(language => language.split('-')[0]));
3080 let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();
3081
3082 // Use first browser language when language is 'auto'
3083 if (language === 'auto') {
3084 [language] = languages;
3085 }
3086 let active = this.storage.get('captions');
3087 if (!is.boolean(active)) {
3088 ({
3089 active
3090 } = this.config.captions);
3091 }
3092 Object.assign(this.captions, {
3093 toggled: false,
3094 active,
3095 language,
3096 languages
3097 });
3098
3099 // Watch changes to textTracks and update captions menu
3100 if (this.isHTML5) {
3101 const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
3102 on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
3103 }
3104
3105 // Update available languages in list next tick (the event must not be triggered before the listeners)
3106 setTimeout(captions.update.bind(this), 0);
3107 },
3108 // Update available language options in settings based on tracks
3109 update() {
3110 const tracks = captions.getTracks.call(this, true);
3111 // Get the wanted language
3112 const {
3113 active,
3114 language,
3115 meta,
3116 currentTrackNode
3117 } = this.captions;
3118 const languageExists = Boolean(tracks.find(track => track.language === language));
3119
3120 // Handle tracks (add event listener and "pseudo"-default)
3121 if (this.isHTML5 && this.isVideo) {
3122 tracks.filter(track => !meta.get(track)).forEach(track => {
3123 this.debug.log('Track added', track);
3124
3125 // Attempt to store if the original dom element was "default"
3126 meta.set(track, {
3127 default: track.mode === 'showing'
3128 });
3129
3130 // Turn off native caption rendering to avoid double captions
3131 // Note: mode='hidden' forces a track to download. To ensure every track
3132 // isn't downloaded at once, only 'showing' tracks should be reassigned
3133 // eslint-disable-next-line no-param-reassign
3134 if (track.mode === 'showing') {
3135 // eslint-disable-next-line no-param-reassign
3136 track.mode = 'hidden';
3137 }
3138
3139 // Add event listener for cue changes
3140 on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
3141 });
3142 }
3143
3144 // Update language first time it matches, or if the previous matching track was removed
3145 if (languageExists && this.language !== language || !tracks.includes(currentTrackNode)) {
3146 captions.setLanguage.call(this, language);
3147 captions.toggle.call(this, active && languageExists);
3148 }
3149
3150 // Enable or disable captions based on track length
3151 if (this.elements) {
3152 toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));
3153 }
3154
3155 // Update available languages in list
3156 if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
3157 controls.setCaptionsMenu.call(this);
3158 }
3159 },
3160 // Toggle captions display
3161 // Used internally for the toggleCaptions method, with the passive option forced to false
3162 toggle(input, passive = true) {
3163 // If there's no full support
3164 if (!this.supported.ui) {
3165 return;
3166 }
3167 const {
3168 toggled
3169 } = this.captions; // Current state
3170 const activeClass = this.config.classNames.captions.active;
3171 // Get the next state
3172 // If the method is called without parameter, toggle based on current value
3173 const active = is.nullOrUndefined(input) ? !toggled : input;
3174
3175 // Update state and trigger event
3176 if (active !== toggled) {
3177 // When passive, don't override user preferences
3178 if (!passive) {
3179 this.captions.active = active;
3180 this.storage.set({
3181 captions: active
3182 });
3183 }
3184
3185 // Force language if the call isn't passive and there is no matching language to toggle to
3186 if (!this.language && active && !passive) {
3187 const tracks = captions.getTracks.call(this);
3188 const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);
3189
3190 // Override user preferences to avoid switching languages if a matching track is added
3191 this.captions.language = track.language;
3192
3193 // Set caption, but don't store in localStorage as user preference
3194 captions.set.call(this, tracks.indexOf(track));
3195 return;
3196 }
3197
3198 // Toggle button if it's enabled
3199 if (this.elements.buttons.captions) {
3200 this.elements.buttons.captions.pressed = active;
3201 }
3202
3203 // Add class hook
3204 toggleClass(this.elements.container, activeClass, active);
3205 this.captions.toggled = active;
3206
3207 // Update settings menu
3208 controls.updateSetting.call(this, 'captions');
3209
3210 // Trigger event (not used internally)
3211 triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
3212 }
3213
3214 // Wait for the call stack to clear before setting mode='hidden'
3215 // on the active track - forcing the browser to download it
3216 setTimeout(() => {
3217 if (active && this.captions.toggled) {
3218 this.captions.currentTrackNode.mode = 'hidden';
3219 }
3220 });
3221 },
3222 // Set captions by track index
3223 // Used internally for the currentTrack setter with the passive option forced to false
3224 set(index, passive = true) {
3225 const tracks = captions.getTracks.call(this);
3226
3227 // Disable captions if setting to -1
3228 if (index === -1) {
3229 captions.toggle.call(this, false, passive);
3230 return;
3231 }
3232 if (!is.number(index)) {
3233 this.debug.warn('Invalid caption argument', index);
3234 return;
3235 }
3236 if (!(index in tracks)) {
3237 this.debug.warn('Track not found', index);
3238 return;
3239 }
3240 if (this.captions.currentTrack !== index) {
3241 this.captions.currentTrack = index;
3242 const track = tracks[index];
3243 const {
3244 language
3245 } = track || {};
3246
3247 // Store reference to node for invalidation on remove
3248 this.captions.currentTrackNode = track;
3249
3250 // Update settings menu
3251 controls.updateSetting.call(this, 'captions');
3252
3253 // When passive, don't override user preferences
3254 if (!passive) {
3255 this.captions.language = language;
3256 this.storage.set({
3257 language
3258 });
3259 }
3260
3261 // Handle Vimeo captions
3262 if (this.isVimeo) {
3263 this.embed.enableTextTrack(language);
3264 }
3265
3266 // Trigger event
3267 triggerEvent.call(this, this.media, 'languagechange');
3268 }
3269
3270 // Show captions
3271 captions.toggle.call(this, true, passive);
3272 if (this.isHTML5 && this.isVideo) {
3273 // If we change the active track while a cue is already displayed we need to update it
3274 captions.updateCues.call(this);
3275 }
3276 },
3277 // Set captions by language
3278 // Used internally for the language setter with the passive option forced to false
3279 setLanguage(input, passive = true) {
3280 if (!is.string(input)) {
3281 this.debug.warn('Invalid language argument', input);
3282 return;
3283 }
3284 // Normalize
3285 const language = input.toLowerCase();
3286 this.captions.language = language;
3287
3288 // Set currentTrack
3289 const tracks = captions.getTracks.call(this);
3290 const track = captions.findTrack.call(this, [language]);
3291 captions.set.call(this, tracks.indexOf(track), passive);
3292 },
3293 // Get current valid caption tracks
3294 // If update is false it will also ignore tracks without metadata
3295 // This is used to "freeze" the language options when captions.update is false
3296 getTracks(update = false) {
3297 // Handle media or textTracks missing or null
3298 const tracks = Array.from((this.media || {}).textTracks || []);
3299 // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
3300 // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
3301 return tracks.filter(track => !this.isHTML5 || update || this.captions.meta.has(track)).filter(track => ['captions', 'subtitles'].includes(track.kind));
3302 },
3303 // Match tracks based on languages and get the first
3304 findTrack(languages, force = false) {
3305 const tracks = captions.getTracks.call(this);
3306 const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
3307 const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
3308 let track;
3309 languages.every(language => {
3310 track = sorted.find(t => t.language === language);
3311 return !track; // Break iteration if there is a match
3312 });
3313
3314 // If no match is found but is required, get first
3315 return track || (force ? sorted[0] : undefined);
3316 },
3317 // Get the current track
3318 getCurrentTrack() {
3319 return captions.getTracks.call(this)[this.currentTrack];
3320 },
3321 // Get UI label for track
3322 getLabel(track) {
3323 let currentTrack = track;
3324 if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {
3325 currentTrack = captions.getCurrentTrack.call(this);
3326 }
3327 if (is.track(currentTrack)) {
3328 if (!is.empty(currentTrack.label)) {
3329 return currentTrack.label;
3330 }
3331 if (!is.empty(currentTrack.language)) {
3332 return track.language.toUpperCase();
3333 }
3334 return i18n.get('enabled', this.config);
3335 }
3336 return i18n.get('disabled', this.config);
3337 },
3338 // Update captions using current track's active cues
3339 // Also optional array argument in case there isn't any track (ex: vimeo)
3340 updateCues(input) {
3341 // Requires UI
3342 if (!this.supported.ui) {
3343 return;
3344 }
3345 if (!is.element(this.elements.captions)) {
3346 this.debug.warn('No captions element to render to');
3347 return;
3348 }
3349
3350 // Only accept array or empty input
3351 if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
3352 this.debug.warn('updateCues: Invalid input', input);
3353 return;
3354 }
3355 let cues = input;
3356
3357 // Get cues from track
3358 if (!cues) {
3359 const track = captions.getCurrentTrack.call(this);
3360 cues = Array.from((track || {}).activeCues || []).map(cue => cue.getCueAsHTML()).map(getHTML);
3361 }
3362
3363 // Set new caption text
3364 const content = cues.map(cueText => cueText.trim()).join('\n');
3365 const changed = content !== this.elements.captions.innerHTML;
3366 if (changed) {
3367 // Empty the container and create a new child element
3368 emptyElement(this.elements.captions);
3369 const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
3370 caption.innerHTML = content;
3371 this.elements.captions.appendChild(caption);
3372
3373 // Trigger event
3374 triggerEvent.call(this, this.media, 'cuechange');
3375 }
3376 }
3377 };
3378
3379 // ==========================================================================
3380 // Plyr default config
3381 // ==========================================================================
3382
3383 const defaults = {
3384 // Disable
3385 enabled: true,
3386 // Custom media title
3387 title: '',
3388 // Logging to console
3389 debug: false,
3390 // Auto play (if supported)
3391 autoplay: false,
3392 // Only allow one media playing at once (vimeo only)
3393 autopause: true,
3394 // Allow inline playback on iOS
3395 playsinline: true,
3396 // Default time to skip when rewind/fast forward
3397 seekTime: 10,
3398 // Default volume
3399 volume: 1,
3400 muted: false,
3401 // Pass a custom duration
3402 duration: null,
3403 // Display the media duration on load in the current time position
3404 // If you have opted to display both duration and currentTime, this is ignored
3405 displayDuration: true,
3406 // Invert the current time to be a countdown
3407 invertTime: true,
3408 // Clicking the currentTime inverts it's value to show time left rather than elapsed
3409 toggleInvert: true,
3410 // Force an aspect ratio
3411 // The format must be `'w:h'` (e.g. `'16:9'`)
3412 ratio: null,
3413 // Click video container to play/pause
3414 clickToPlay: true,
3415 // Auto hide the controls
3416 hideControls: true,
3417 // Reset to start when playback ended
3418 resetOnEnd: false,
3419 // Disable the standard context menu
3420 disableContextMenu: true,
3421 // Sprite (for icons)
3422 loadSprite: true,
3423 iconPrefix: 'plyr',
3424 iconUrl: 'https://cdn.plyr.io/3.7.8/plyr.svg',
3425 // Blank video (used to prevent errors on source change)
3426 blankVideo: 'https://cdn.plyr.io/static/blank.mp4',
3427 // Quality default
3428 quality: {
3429 default: 576,
3430 // The options to display in the UI, if available for the source media
3431 options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240],
3432 forced: false,
3433 onChange: null
3434 },
3435 // Set loops
3436 loop: {
3437 active: false
3438 // start: null,
3439 // end: null,
3440 },
3441
3442 // Speed default and options to display
3443 speed: {
3444 selected: 1,
3445 // The options to display in the UI, if available for the source media (e.g. Vimeo and YouTube only support 0.5x-4x)
3446 options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4]
3447 },
3448 // Keyboard shortcut settings
3449 keyboard: {
3450 focused: true,
3451 global: false
3452 },
3453 // Display tooltips
3454 tooltips: {
3455 controls: false,
3456 seek: true
3457 },
3458 // Captions settings
3459 captions: {
3460 active: false,
3461 language: 'auto',
3462 // Listen to new tracks added after Plyr is initialized.
3463 // This is needed for streaming captions, but may result in unselectable options
3464 update: false
3465 },
3466 // Fullscreen settings
3467 fullscreen: {
3468 enabled: true,
3469 // Allow fullscreen?
3470 fallback: true,
3471 // Fallback using full viewport/window
3472 iosNative: false // Use the native fullscreen in iOS (disables custom controls)
3473 // Selector for the fullscreen container so contextual / non-player content can remain visible in fullscreen mode
3474 // Non-ancestors of the player element will be ignored
3475 // container: null, // defaults to the player element
3476 },
3477
3478 // Local storage
3479 storage: {
3480 enabled: true,
3481 key: 'plyr'
3482 },
3483 // Default controls
3484 controls: ['play-large',
3485 // 'restart',
3486 // 'rewind',
3487 'play',
3488 // 'fast-forward',
3489 'progress', 'current-time',
3490 // 'duration',
3491 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay',
3492 // 'download',
3493 'fullscreen'],
3494 settings: ['captions', 'quality', 'speed'],
3495 // Localisation
3496 i18n: {
3497 restart: 'Restart',
3498 rewind: 'Rewind {seektime}s',
3499 play: 'Play',
3500 pause: 'Pause',
3501 fastForward: 'Forward {seektime}s',
3502 seek: 'Seek',
3503 seekLabel: '{currentTime} of {duration}',
3504 played: 'Played',
3505 buffered: 'Buffered',
3506 currentTime: 'Current time',
3507 duration: 'Duration',
3508 volume: 'Volume',
3509 mute: 'Mute',
3510 unmute: 'Unmute',
3511 enableCaptions: 'Enable captions',
3512 disableCaptions: 'Disable captions',
3513 download: 'Download',
3514 enterFullscreen: 'Enter fullscreen',
3515 exitFullscreen: 'Exit fullscreen',
3516 frameTitle: 'Player for {title}',
3517 captions: 'Captions',
3518 settings: 'Settings',
3519 pip: 'PIP',
3520 menuBack: 'Go back to previous menu',
3521 speed: 'Speed',
3522 normal: 'Normal',
3523 quality: 'Quality',
3524 loop: 'Loop',
3525 start: 'Start',
3526 end: 'End',
3527 all: 'All',
3528 reset: 'Reset',
3529 disabled: 'Disabled',
3530 enabled: 'Enabled',
3531 advertisement: 'Ad',
3532 qualityBadge: {
3533 2160: '4K',
3534 1440: 'HD',
3535 1080: 'HD',
3536 720: 'HD',
3537 576: 'SD',
3538 480: 'SD'
3539 }
3540 },
3541 // URLs
3542 urls: {
3543 download: null,
3544 vimeo: {
3545 sdk: 'https://player.vimeo.com/api/player.js',
3546 iframe: 'https://player.vimeo.com/video/{0}?{1}',
3547 api: 'https://vimeo.com/api/oembed.json?url={0}'
3548 },
3549 youtube: {
3550 sdk: 'https://www.youtube.com/iframe_api',
3551 api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}'
3552 },
3553 googleIMA: {
3554 sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js'
3555 }
3556 },
3557 // Custom control listeners
3558 listeners: {
3559 seek: null,
3560 play: null,
3561 pause: null,
3562 restart: null,
3563 rewind: null,
3564 fastForward: null,
3565 mute: null,
3566 volume: null,
3567 captions: null,
3568 download: null,
3569 fullscreen: null,
3570 pip: null,
3571 airplay: null,
3572 speed: null,
3573 quality: null,
3574 loop: null,
3575 language: null
3576 },
3577 // Events to watch and bubble
3578 events: [
3579 // Events to watch on HTML5 media elements and bubble
3580 // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events
3581 'ended', 'progress', 'stalled', 'playing', 'waiting', 'canplay', 'canplaythrough', 'loadstart', 'loadeddata', 'loadedmetadata', 'timeupdate', 'volumechange', 'play', 'pause', 'error', 'seeking', 'seeked', 'emptied', 'ratechange', 'cuechange',
3582 // Custom events
3583 'download', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', 'captionsdisabled', 'languagechange', 'controlshidden', 'controlsshown', 'ready',
3584 // YouTube
3585 'statechange',
3586 // Quality
3587 'qualitychange',
3588 // Ads
3589 'adsloaded', 'adscontentpause', 'adscontentresume', 'adstarted', 'adsmidpoint', 'adscomplete', 'adsallcomplete', 'adsimpression', 'adsclick'],
3590 // Selectors
3591 // Change these to match your template if using custom HTML
3592 selectors: {
3593 editable: 'input, textarea, select, [contenteditable]',
3594 container: '.plyr',
3595 controls: {
3596 container: null,
3597 wrapper: '.plyr__controls'
3598 },
3599 labels: '[data-plyr]',
3600 buttons: {
3601 play: '[data-plyr="play"]',
3602 pause: '[data-plyr="pause"]',
3603 restart: '[data-plyr="restart"]',
3604 rewind: '[data-plyr="rewind"]',
3605 fastForward: '[data-plyr="fast-forward"]',
3606 mute: '[data-plyr="mute"]',
3607 captions: '[data-plyr="captions"]',
3608 download: '[data-plyr="download"]',
3609 fullscreen: '[data-plyr="fullscreen"]',
3610 pip: '[data-plyr="pip"]',
3611 airplay: '[data-plyr="airplay"]',
3612 settings: '[data-plyr="settings"]',
3613 loop: '[data-plyr="loop"]'
3614 },
3615 inputs: {
3616 seek: '[data-plyr="seek"]',
3617 volume: '[data-plyr="volume"]',
3618 speed: '[data-plyr="speed"]',
3619 language: '[data-plyr="language"]',
3620 quality: '[data-plyr="quality"]'
3621 },
3622 display: {
3623 currentTime: '.plyr__time--current',
3624 duration: '.plyr__time--duration',
3625 buffer: '.plyr__progress__buffer',
3626 loop: '.plyr__progress__loop',
3627 // Used later
3628 volume: '.plyr__volume--display'
3629 },
3630 progress: '.plyr__progress',
3631 captions: '.plyr__captions',
3632 caption: '.plyr__caption'
3633 },
3634 // Class hooks added to the player in different states
3635 classNames: {
3636 type: 'plyr--{0}',
3637 provider: 'plyr--{0}',
3638 video: 'plyr__video-wrapper',
3639 embed: 'plyr__video-embed',
3640 videoFixedRatio: 'plyr__video-wrapper--fixed-ratio',
3641 embedContainer: 'plyr__video-embed__container',
3642 poster: 'plyr__poster',
3643 posterEnabled: 'plyr__poster-enabled',
3644 ads: 'plyr__ads',
3645 control: 'plyr__control',
3646 controlPressed: 'plyr__control--pressed',
3647 playing: 'plyr--playing',
3648 paused: 'plyr--paused',
3649 stopped: 'plyr--stopped',
3650 loading: 'plyr--loading',
3651 hover: 'plyr--hover',
3652 tooltip: 'plyr__tooltip',
3653 cues: 'plyr__cues',
3654 marker: 'plyr__progress__marker',
3655 hidden: 'plyr__sr-only',
3656 hideControls: 'plyr--hide-controls',
3657 isTouch: 'plyr--is-touch',
3658 uiSupported: 'plyr--full-ui',
3659 noTransition: 'plyr--no-transition',
3660 display: {
3661 time: 'plyr__time'
3662 },
3663 menu: {
3664 value: 'plyr__menu__value',
3665 badge: 'plyr__badge',
3666 open: 'plyr--menu-open'
3667 },
3668 captions: {
3669 enabled: 'plyr--captions-enabled',
3670 active: 'plyr--captions-active'
3671 },
3672 fullscreen: {
3673 enabled: 'plyr--fullscreen-enabled',
3674 fallback: 'plyr--fullscreen-fallback'
3675 },
3676 pip: {
3677 supported: 'plyr--pip-supported',
3678 active: 'plyr--pip-active'
3679 },
3680 airplay: {
3681 supported: 'plyr--airplay-supported',
3682 active: 'plyr--airplay-active'
3683 },
3684 previewThumbnails: {
3685 // Tooltip thumbs
3686 thumbContainer: 'plyr__preview-thumb',
3687 thumbContainerShown: 'plyr__preview-thumb--is-shown',
3688 imageContainer: 'plyr__preview-thumb__image-container',
3689 timeContainer: 'plyr__preview-thumb__time-container',
3690 // Scrubbing
3691 scrubbingContainer: 'plyr__preview-scrubbing',
3692 scrubbingContainerShown: 'plyr__preview-scrubbing--is-shown'
3693 }
3694 },
3695 // Embed attributes
3696 attributes: {
3697 embed: {
3698 provider: 'data-plyr-provider',
3699 id: 'data-plyr-embed-id',
3700 hash: 'data-plyr-embed-hash'
3701 }
3702 },
3703 // Advertisements plugin
3704 // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio
3705 ads: {
3706 enabled: false,
3707 publisherId: '',
3708 tagUrl: ''
3709 },
3710 // Preview Thumbnails plugin
3711 previewThumbnails: {
3712 enabled: false,
3713 src: ''
3714 },
3715 // Vimeo plugin
3716 vimeo: {
3717 byline: false,
3718 portrait: false,
3719 title: false,
3720 speed: true,
3721 transparent: false,
3722 // Custom settings from Plyr
3723 customControls: true,
3724 referrerPolicy: null,
3725 // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy
3726 // Whether the owner of the video has a Pro or Business account
3727 // (which allows us to properly hide controls without CSS hacks, etc)
3728 premium: false
3729 },
3730 // YouTube plugin
3731 youtube: {
3732 rel: 0,
3733 // No related vids
3734 showinfo: 0,
3735 // Hide info
3736 iv_load_policy: 3,
3737 // Hide annotations
3738 modestbranding: 1,
3739 // Hide logos as much as possible (they still show one in the corner when paused)
3740 // Custom settings from Plyr
3741 customControls: true,
3742 noCookie: false // Whether to use an alternative version of YouTube without cookies
3743 },
3744
3745 // Media Metadata
3746 mediaMetadata: {
3747 title: '',
3748 artist: '',
3749 album: '',
3750 artwork: []
3751 },
3752 // Markers
3753 markers: {
3754 enabled: false,
3755 points: []
3756 }
3757 };
3758
3759 // ==========================================================================
3760 // Plyr states
3761 // ==========================================================================
3762
3763 const pip = {
3764 active: 'picture-in-picture',
3765 inactive: 'inline'
3766 };
3767
3768 // ==========================================================================
3769 // Plyr supported types and providers
3770 // ==========================================================================
3771
3772 const providers = {
3773 html5: 'html5',
3774 youtube: 'youtube',
3775 vimeo: 'vimeo'
3776 };
3777 const types = {
3778 audio: 'audio',
3779 video: 'video'
3780 };
3781
3782 /**
3783 * Get provider by URL
3784 * @param {String} url
3785 */
3786 function getProviderByUrl(url) {
3787 // YouTube
3788 if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(url)) {
3789 return providers.youtube;
3790 }
3791
3792 // Vimeo
3793 if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) {
3794 return providers.vimeo;
3795 }
3796 return null;
3797 }
3798
3799 // ==========================================================================
3800 // Console wrapper
3801 // ==========================================================================
3802
3803 const noop = () => {};
3804 class Console {
3805 constructor(enabled = false) {
3806 this.enabled = window.console && enabled;
3807 if (this.enabled) {
3808 this.log('Debugging enabled');
3809 }
3810 }
3811 get log() {
3812 // eslint-disable-next-line no-console
3813 return this.enabled ? Function.prototype.bind.call(console.log, console) : noop;
3814 }
3815 get warn() {
3816 // eslint-disable-next-line no-console
3817 return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop;
3818 }
3819 get error() {
3820 // eslint-disable-next-line no-console
3821 return this.enabled ? Function.prototype.bind.call(console.error, console) : noop;
3822 }
3823 }
3824
3825 class Fullscreen {
3826 constructor(player) {
3827 _defineProperty$1(this, "onChange", () => {
3828 if (!this.supported) return;
3829
3830 // Update toggle button
3831 const button = this.player.elements.buttons.fullscreen;
3832 if (is.element(button)) {
3833 button.pressed = this.active;
3834 }
3835
3836 // Always trigger events on the plyr / media element (not a fullscreen container) and let them bubble up
3837 const target = this.target === this.player.media ? this.target : this.player.elements.container;
3838 // Trigger an event
3839 triggerEvent.call(this.player, target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);
3840 });
3841 _defineProperty$1(this, "toggleFallback", (toggle = false) => {
3842 // Store or restore scroll position
3843 if (toggle) {
3844 this.scrollPosition = {
3845 x: window.scrollX ?? 0,
3846 y: window.scrollY ?? 0
3847 };
3848 } else {
3849 window.scrollTo(this.scrollPosition.x, this.scrollPosition.y);
3850 }
3851
3852 // Toggle scroll
3853 document.body.style.overflow = toggle ? 'hidden' : '';
3854
3855 // Toggle class hook
3856 toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);
3857
3858 // Force full viewport on iPhone X+
3859 if (browser.isIos) {
3860 let viewport = document.head.querySelector('meta[name="viewport"]');
3861 const property = 'viewport-fit=cover';
3862
3863 // Inject the viewport meta if required
3864 if (!viewport) {
3865 viewport = document.createElement('meta');
3866 viewport.setAttribute('name', 'viewport');
3867 }
3868
3869 // Check if the property already exists
3870 const hasProperty = is.string(viewport.content) && viewport.content.includes(property);
3871 if (toggle) {
3872 this.cleanupViewport = !hasProperty;
3873 if (!hasProperty) viewport.content += `,${property}`;
3874 } else if (this.cleanupViewport) {
3875 viewport.content = viewport.content.split(',').filter(part => part.trim() !== property).join(',');
3876 }
3877 }
3878
3879 // Toggle button and fire events
3880 this.onChange();
3881 });
3882 // Trap focus inside container
3883 _defineProperty$1(this, "trapFocus", event => {
3884 // Bail if iOS/iPadOS, not active, not the tab key
3885 if (browser.isIos || browser.isIPadOS || !this.active || event.key !== 'Tab') return;
3886
3887 // Get the current focused element
3888 const focused = document.activeElement;
3889 const focusable = getElements.call(this.player, 'a[href], button:not(:disabled), input:not(:disabled), [tabindex]');
3890 const [first] = focusable;
3891 const last = focusable[focusable.length - 1];
3892 if (focused === last && !event.shiftKey) {
3893 // Move focus to first element that can be tabbed if Shift isn't used
3894 first.focus();
3895 event.preventDefault();
3896 } else if (focused === first && event.shiftKey) {
3897 // Move focus to last element that can be tabbed if Shift is used
3898 last.focus();
3899 event.preventDefault();
3900 }
3901 });
3902 // Update UI
3903 _defineProperty$1(this, "update", () => {
3904 if (this.supported) {
3905 let mode;
3906 if (this.forceFallback) mode = 'Fallback (forced)';else if (Fullscreen.nativeSupported) mode = 'Native';else mode = 'Fallback';
3907 this.player.debug.log(`${mode} fullscreen enabled`);
3908 } else {
3909 this.player.debug.log('Fullscreen not supported and fallback disabled');
3910 }
3911
3912 // Add styling hook to show button
3913 toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.supported);
3914 });
3915 // Make an element fullscreen
3916 _defineProperty$1(this, "enter", () => {
3917 if (!this.supported) return;
3918
3919 // iOS native fullscreen doesn't need the request step
3920 if (browser.isIos && this.player.config.fullscreen.iosNative) {
3921 if (this.player.isVimeo) {
3922 this.player.embed.requestFullscreen();
3923 } else {
3924 this.target.webkitEnterFullscreen();
3925 }
3926 } else if (!Fullscreen.nativeSupported || this.forceFallback) {
3927 this.toggleFallback(true);
3928 } else if (!this.prefix) {
3929 this.target.requestFullscreen({
3930 navigationUI: 'hide'
3931 });
3932 } else if (!is.empty(this.prefix)) {
3933 this.target[`${this.prefix}Request${this.property}`]();
3934 }
3935 });
3936 // Bail from fullscreen
3937 _defineProperty$1(this, "exit", () => {
3938 if (!this.supported) return;
3939
3940 // iOS native fullscreen
3941 if (browser.isIos && this.player.config.fullscreen.iosNative) {
3942 if (this.player.isVimeo) {
3943 this.player.embed.exitFullscreen();
3944 } else {
3945 this.target.webkitEnterFullscreen();
3946 }
3947 silencePromise(this.player.play());
3948 } else if (!Fullscreen.nativeSupported || this.forceFallback) {
3949 this.toggleFallback(false);
3950 } else if (!this.prefix) {
3951 (document.cancelFullScreen || document.exitFullscreen).call(document);
3952 } else if (!is.empty(this.prefix)) {
3953 const action = this.prefix === 'moz' ? 'Cancel' : 'Exit';
3954 document[`${this.prefix}${action}${this.property}`]();
3955 }
3956 });
3957 // Toggle state
3958 _defineProperty$1(this, "toggle", () => {
3959 if (!this.active) this.enter();else this.exit();
3960 });
3961 // Keep reference to parent
3962 this.player = player;
3963
3964 // Get prefix
3965 this.prefix = Fullscreen.prefix;
3966 this.property = Fullscreen.property;
3967
3968 // Scroll position
3969 this.scrollPosition = {
3970 x: 0,
3971 y: 0
3972 };
3973
3974 // Force the use of 'full window/browser' rather than fullscreen
3975 this.forceFallback = player.config.fullscreen.fallback === 'force';
3976
3977 // Get the fullscreen element
3978 // Checks container is an ancestor, defaults to null
3979 this.player.elements.fullscreen = player.config.fullscreen.container && closest$1(this.player.elements.container, player.config.fullscreen.container);
3980
3981 // Register event listeners
3982 // Handle event (incase user presses escape etc)
3983 on.call(this.player, document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => {
3984 // TODO: Filter for target??
3985 this.onChange();
3986 });
3987
3988 // Fullscreen toggle on double click
3989 on.call(this.player, this.player.elements.container, 'dblclick', event => {
3990 // Ignore double click in controls
3991 if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) {
3992 return;
3993 }
3994 this.player.listeners.proxy(event, this.toggle, 'fullscreen');
3995 });
3996
3997 // Tap focus when in fullscreen
3998 on.call(this, this.player.elements.container, 'keydown', event => this.trapFocus(event));
3999
4000 // Update the UI
4001 this.update();
4002 }
4003
4004 // Determine if native supported
4005 static get nativeSupported() {
4006 return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled);
4007 }
4008
4009 // If we're actually using native
4010 get useNative() {
4011 return Fullscreen.nativeSupported && !this.forceFallback;
4012 }
4013
4014 // Get the prefix for handlers
4015 static get prefix() {
4016 // No prefix
4017 if (is.function(document.exitFullscreen)) return '';
4018
4019 // Check for fullscreen support by vendor prefix
4020 let value = '';
4021 const prefixes = ['webkit', 'moz', 'ms'];
4022 prefixes.some(pre => {
4023 if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) {
4024 value = pre;
4025 return true;
4026 }
4027 return false;
4028 });
4029 return value;
4030 }
4031 static get property() {
4032 return this.prefix === 'moz' ? 'FullScreen' : 'Fullscreen';
4033 }
4034
4035 // Determine if fullscreen is supported
4036 get supported() {
4037 return [
4038 // Fullscreen is enabled in config
4039 this.player.config.fullscreen.enabled,
4040 // Must be a video
4041 this.player.isVideo,
4042 // Either native is supported or fallback enabled
4043 Fullscreen.nativeSupported || this.player.config.fullscreen.fallback,
4044 // YouTube has no way to trigger fullscreen, so on devices with no native support, playsinline
4045 // must be enabled and iosNative fullscreen must be disabled to offer the fullscreen fallback
4046 !this.player.isYouTube || Fullscreen.nativeSupported || !browser.isIos || this.player.config.playsinline && !this.player.config.fullscreen.iosNative].every(Boolean);
4047 }
4048
4049 // Get active state
4050 get active() {
4051 if (!this.supported) return false;
4052
4053 // Fallback using classname
4054 if (!Fullscreen.nativeSupported || this.forceFallback) {
4055 return hasClass(this.target, this.player.config.classNames.fullscreen.fallback);
4056 }
4057 const element = !this.prefix ? this.target.getRootNode().fullscreenElement : this.target.getRootNode()[`${this.prefix}${this.property}Element`];
4058 return element && element.shadowRoot ? element === this.target.getRootNode().host : element === this.target;
4059 }
4060
4061 // Get target element
4062 get target() {
4063 return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.fullscreen ?? this.player.elements.container;
4064 }
4065 }
4066
4067 // ==========================================================================
4068 // Load image avoiding xhr/fetch CORS issues
4069 // Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded
4070 // By default it checks if it is at least 1px, but you can add a second argument to change this
4071 // ==========================================================================
4072
4073 function loadImage(src, minWidth = 1) {
4074 return new Promise((resolve, reject) => {
4075 const image = new Image();
4076 const handler = () => {
4077 delete image.onload;
4078 delete image.onerror;
4079 (image.naturalWidth >= minWidth ? resolve : reject)(image);
4080 };
4081 Object.assign(image, {
4082 onload: handler,
4083 onerror: handler,
4084 src
4085 });
4086 });
4087 }
4088
4089 // ==========================================================================
4090 const ui = {
4091 addStyleHook() {
4092 toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
4093 toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
4094 },
4095 // Toggle native HTML5 media controls
4096 toggleNativeControls(toggle = false) {
4097 if (toggle && this.isHTML5) {
4098 this.media.setAttribute('controls', '');
4099 } else {
4100 this.media.removeAttribute('controls');
4101 }
4102 },
4103 // Setup the UI
4104 build() {
4105 // Re-attach media element listeners
4106 // TODO: Use event bubbling?
4107 this.listeners.media();
4108
4109 // Don't setup interface if no support
4110 if (!this.supported.ui) {
4111 this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);
4112
4113 // Restore native controls
4114 ui.toggleNativeControls.call(this, true);
4115
4116 // Bail
4117 return;
4118 }
4119
4120 // Inject custom controls if not present
4121 if (!is.element(this.elements.controls)) {
4122 // Inject custom controls
4123 controls.inject.call(this);
4124
4125 // Re-attach control listeners
4126 this.listeners.controls();
4127 }
4128
4129 // Remove native controls
4130 ui.toggleNativeControls.call(this);
4131
4132 // Setup captions for HTML5
4133 if (this.isHTML5) {
4134 captions.setup.call(this);
4135 }
4136
4137 // Reset volume
4138 this.volume = null;
4139
4140 // Reset mute state
4141 this.muted = null;
4142
4143 // Reset loop state
4144 this.loop = null;
4145
4146 // Reset quality setting
4147 this.quality = null;
4148
4149 // Reset speed
4150 this.speed = null;
4151
4152 // Reset volume display
4153 controls.updateVolume.call(this);
4154
4155 // Reset time display
4156 controls.timeUpdate.call(this);
4157
4158 // Reset duration display
4159 controls.durationUpdate.call(this);
4160
4161 // Update the UI
4162 ui.checkPlaying.call(this);
4163
4164 // Check for picture-in-picture support
4165 toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo);
4166
4167 // Check for airplay support
4168 toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
4169
4170 // Add touch class
4171 toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);
4172
4173 // Ready for API calls
4174 this.ready = true;
4175
4176 // Ready event at end of execution stack
4177 setTimeout(() => {
4178 triggerEvent.call(this, this.media, 'ready');
4179 }, 0);
4180
4181 // Set the title
4182 ui.setTitle.call(this);
4183
4184 // Assure the poster image is set, if the property was added before the element was created
4185 if (this.poster) {
4186 ui.setPoster.call(this, this.poster, false).catch(() => {});
4187 }
4188
4189 // Manually set the duration if user has overridden it.
4190 // The event listeners for it doesn't get called if preload is disabled (#701)
4191 if (this.config.duration) {
4192 controls.durationUpdate.call(this);
4193 }
4194
4195 // Media metadata
4196 if (this.config.mediaMetadata) {
4197 controls.setMediaMetadata.call(this);
4198 }
4199 },
4200 // Setup aria attribute for play and iframe title
4201 setTitle() {
4202 // Find the current text
4203 let label = i18n.get('play', this.config);
4204
4205 // If there's a media title set, use that for the label
4206 if (is.string(this.config.title) && !is.empty(this.config.title)) {
4207 label += `, ${this.config.title}`;
4208 }
4209
4210 // If there's a play button, set label
4211 Array.from(this.elements.buttons.play || []).forEach(button => {
4212 button.setAttribute('aria-label', label);
4213 });
4214
4215 // Set iframe title
4216 // https://github.com/sampotts/plyr/issues/124
4217 if (this.isEmbed) {
4218 const iframe = getElement.call(this, 'iframe');
4219 if (!is.element(iframe)) {
4220 return;
4221 }
4222
4223 // Default to media type
4224 const title = !is.empty(this.config.title) ? this.config.title : 'video';
4225 const format = i18n.get('frameTitle', this.config);
4226 iframe.setAttribute('title', format.replace('{title}', title));
4227 }
4228 },
4229 // Toggle poster
4230 togglePoster(enable) {
4231 toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
4232 },
4233 // Set the poster image (async)
4234 // Used internally for the poster setter, with the passive option forced to false
4235 setPoster(poster, passive = true) {
4236 // Don't override if call is passive
4237 if (passive && this.poster) {
4238 return Promise.reject(new Error('Poster already set'));
4239 }
4240
4241 // Set property synchronously to respect the call order
4242 this.media.setAttribute('data-poster', poster);
4243
4244 // Show the poster
4245 this.elements.poster.removeAttribute('hidden');
4246
4247 // Wait until ui is ready
4248 return ready.call(this)
4249 // Load image
4250 .then(() => loadImage(poster)).catch(error => {
4251 // Hide poster on error unless it's been set by another call
4252 if (poster === this.poster) {
4253 ui.togglePoster.call(this, false);
4254 }
4255 // Rethrow
4256 throw error;
4257 }).then(() => {
4258 // Prevent race conditions
4259 if (poster !== this.poster) {
4260 throw new Error('setPoster cancelled by later call to setPoster');
4261 }
4262 }).then(() => {
4263 Object.assign(this.elements.poster.style, {
4264 backgroundImage: `url('${poster}')`,
4265 // Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube)
4266 backgroundSize: ''
4267 });
4268 ui.togglePoster.call(this, true);
4269 return poster;
4270 });
4271 },
4272 // Check playing state
4273 checkPlaying(event) {
4274 // Class hooks
4275 toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
4276 toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
4277 toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);
4278
4279 // Set state
4280 Array.from(this.elements.buttons.play || []).forEach(target => {
4281 Object.assign(target, {
4282 pressed: this.playing
4283 });
4284 target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config));
4285 });
4286
4287 // Only update controls on non timeupdate events
4288 if (is.event(event) && event.type === 'timeupdate') {
4289 return;
4290 }
4291
4292 // Toggle controls
4293 ui.toggleControls.call(this);
4294 },
4295 // Check if media is loading
4296 checkLoading(event) {
4297 this.loading = ['stalled', 'waiting'].includes(event.type);
4298
4299 // Clear timer
4300 clearTimeout(this.timers.loading);
4301
4302 // Timer to prevent flicker when seeking
4303 this.timers.loading = setTimeout(() => {
4304 // Update progress bar loading class state
4305 toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
4306
4307 // Update controls visibility
4308 ui.toggleControls.call(this);
4309 }, this.loading ? 250 : 0);
4310 },
4311 // Toggle controls based on state and `force` argument
4312 toggleControls(force) {
4313 const {
4314 controls: controlsElement
4315 } = this.elements;
4316 if (controlsElement && this.config.hideControls) {
4317 // Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)
4318 const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now();
4319
4320 // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide
4321 this.toggleControls(Boolean(force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek));
4322 }
4323 },
4324 // Migrate any custom properties from the media to the parent
4325 migrateStyles() {
4326 // Loop through values (as they are the keys when the object is spread 🤔)
4327 Object.values({
4328 ...this.media.style
4329 })
4330 // We're only fussed about Plyr specific properties
4331 .filter(key => !is.empty(key) && is.string(key) && key.startsWith('--plyr')).forEach(key => {
4332 // Set on the container
4333 this.elements.container.style.setProperty(key, this.media.style.getPropertyValue(key));
4334
4335 // Clean up from media element
4336 this.media.style.removeProperty(key);
4337 });
4338
4339 // Remove attribute if empty
4340 if (is.empty(this.media.style)) {
4341 this.media.removeAttribute('style');
4342 }
4343 }
4344 };
4345
4346 class Listeners {
4347 constructor(_player) {
4348 // Device is touch enabled
4349 _defineProperty$1(this, "firstTouch", () => {
4350 const {
4351 player
4352 } = this;
4353 const {
4354 elements
4355 } = player;
4356 player.touch = true;
4357
4358 // Add touch class
4359 toggleClass(elements.container, player.config.classNames.isTouch, true);
4360 });
4361 // Global window & document listeners
4362 _defineProperty$1(this, "global", (toggle = true) => {
4363 const {
4364 player
4365 } = this;
4366
4367 // Keyboard shortcuts
4368 if (player.config.keyboard.global) {
4369 toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false);
4370 }
4371
4372 // Click anywhere closes menu
4373 toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle);
4374
4375 // Detect touch by events
4376 once.call(player, document.body, 'touchstart', this.firstTouch);
4377 });
4378 // Container listeners
4379 _defineProperty$1(this, "container", () => {
4380 const {
4381 player
4382 } = this;
4383 const {
4384 config,
4385 elements,
4386 timers
4387 } = player;
4388
4389 // Keyboard shortcuts
4390 if (!config.keyboard.global && config.keyboard.focused) {
4391 on.call(player, elements.container, 'keydown keyup', this.handleKey, false);
4392 }
4393
4394 // Toggle controls on mouse events and entering fullscreen
4395 on.call(player, elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => {
4396 const {
4397 controls: controlsElement
4398 } = elements;
4399
4400 // Remove button states for fullscreen
4401 if (controlsElement && event.type === 'enterfullscreen') {
4402 controlsElement.pressed = false;
4403 controlsElement.hover = false;
4404 }
4405
4406 // Show, then hide after a timeout unless another control event occurs
4407 const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);
4408 let delay = 0;
4409 if (show) {
4410 ui.toggleControls.call(player, true);
4411 // Use longer timeout for touch devices
4412 delay = player.touch ? 3000 : 2000;
4413 }
4414
4415 // Clear timer
4416 clearTimeout(timers.controls);
4417
4418 // Set new timer to prevent flicker when seeking
4419 timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);
4420 });
4421
4422 // Set a gutter for Vimeo
4423 const setGutter = () => {
4424 if (!player.isVimeo || player.config.vimeo.premium) {
4425 return;
4426 }
4427 const target = elements.wrapper;
4428 const {
4429 active
4430 } = player.fullscreen;
4431 const [videoWidth, videoHeight] = getAspectRatio.call(player);
4432 const useNativeAspectRatio = supportsCSS(`aspect-ratio: ${videoWidth} / ${videoHeight}`);
4433
4434 // If not active, remove styles
4435 if (!active) {
4436 if (useNativeAspectRatio) {
4437 target.style.width = null;
4438 target.style.height = null;
4439 } else {
4440 target.style.maxWidth = null;
4441 target.style.margin = null;
4442 }
4443 return;
4444 }
4445
4446 // Determine which dimension will overflow and constrain view
4447 const [viewportWidth, viewportHeight] = getViewportSize();
4448 const overflow = viewportWidth / viewportHeight > videoWidth / videoHeight;
4449 if (useNativeAspectRatio) {
4450 target.style.width = overflow ? 'auto' : '100%';
4451 target.style.height = overflow ? '100%' : 'auto';
4452 } else {
4453 target.style.maxWidth = overflow ? `${viewportHeight / videoHeight * videoWidth}px` : null;
4454 target.style.margin = overflow ? '0 auto' : null;
4455 }
4456 };
4457
4458 // Handle resizing
4459 const resized = () => {
4460 clearTimeout(timers.resized);
4461 timers.resized = setTimeout(setGutter, 50);
4462 };
4463 on.call(player, elements.container, 'enterfullscreen exitfullscreen', event => {
4464 const {
4465 target
4466 } = player.fullscreen;
4467
4468 // Ignore events not from target
4469 if (target !== elements.container) {
4470 return;
4471 }
4472
4473 // If it's not an embed and no ratio specified
4474 if (!player.isEmbed && is.empty(player.config.ratio)) {
4475 return;
4476 }
4477
4478 // Set Vimeo gutter
4479 setGutter();
4480
4481 // Watch for resizes
4482 const method = event.type === 'enterfullscreen' ? on : off;
4483 method.call(player, window, 'resize', resized);
4484 });
4485 });
4486 // Listen for media events
4487 _defineProperty$1(this, "media", () => {
4488 const {
4489 player
4490 } = this;
4491 const {
4492 elements
4493 } = player;
4494
4495 // Time change on media
4496 on.call(player, player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(player, event));
4497
4498 // Display duration
4499 on.call(player, player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(player, event));
4500
4501 // Handle the media finishing
4502 on.call(player, player.media, 'ended', () => {
4503 // Show poster on end
4504 if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {
4505 // Restart
4506 player.restart();
4507
4508 // Call pause otherwise IE11 will start playing the video again
4509 player.pause();
4510 }
4511 });
4512
4513 // Check for buffer progress
4514 on.call(player, player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(player, event));
4515
4516 // Handle volume changes
4517 on.call(player, player.media, 'volumechange', event => controls.updateVolume.call(player, event));
4518
4519 // Handle play/pause
4520 on.call(player, player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(player, event));
4521
4522 // Loading state
4523 on.call(player, player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(player, event));
4524
4525 // Click video
4526 if (player.supported.ui && player.config.clickToPlay && !player.isAudio) {
4527 // Re-fetch the wrapper
4528 const wrapper = getElement.call(player, `.${player.config.classNames.video}`);
4529
4530 // Bail if there's no wrapper (this should never happen)
4531 if (!is.element(wrapper)) {
4532 return;
4533 }
4534
4535 // On click play, pause or restart
4536 on.call(player, elements.container, 'click', event => {
4537 const targets = [elements.container, wrapper];
4538
4539 // Ignore if click if not container or in video wrapper
4540 if (!targets.includes(event.target) && !wrapper.contains(event.target)) {
4541 return;
4542 }
4543
4544 // Touch devices will just show controls (if hidden)
4545 if (player.touch && player.config.hideControls) {
4546 return;
4547 }
4548 if (player.ended) {
4549 this.proxy(event, player.restart, 'restart');
4550 this.proxy(event, () => {
4551 silencePromise(player.play());
4552 }, 'play');
4553 } else {
4554 this.proxy(event, () => {
4555 silencePromise(player.togglePlay());
4556 }, 'play');
4557 }
4558 });
4559 }
4560
4561 // Disable right click
4562 if (player.supported.ui && player.config.disableContextMenu) {
4563 on.call(player, elements.wrapper, 'contextmenu', event => {
4564 event.preventDefault();
4565 }, false);
4566 }
4567
4568 // Volume change
4569 on.call(player, player.media, 'volumechange', () => {
4570 // Save to storage
4571 player.storage.set({
4572 volume: player.volume,
4573 muted: player.muted
4574 });
4575 });
4576
4577 // Speed change
4578 on.call(player, player.media, 'ratechange', () => {
4579 // Update UI
4580 controls.updateSetting.call(player, 'speed');
4581
4582 // Save to storage
4583 player.storage.set({
4584 speed: player.speed
4585 });
4586 });
4587
4588 // Quality change
4589 on.call(player, player.media, 'qualitychange', event => {
4590 // Update UI
4591 controls.updateSetting.call(player, 'quality', null, event.detail.quality);
4592 });
4593
4594 // Update download link when ready and if quality changes
4595 on.call(player, player.media, 'ready qualitychange', () => {
4596 controls.setDownloadUrl.call(player);
4597 });
4598
4599 // Proxy events to container
4600 // Bubble up key events for Edge
4601 const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' ');
4602 on.call(player, player.media, proxyEvents, event => {
4603 let {
4604 detail = {}
4605 } = event;
4606
4607 // Get error details from media
4608 if (event.type === 'error') {
4609 detail = player.media.error;
4610 }
4611 triggerEvent.call(player, elements.container, event.type, true, detail);
4612 });
4613 });
4614 // Run default and custom handlers
4615 _defineProperty$1(this, "proxy", (event, defaultHandler, customHandlerKey) => {
4616 const {
4617 player
4618 } = this;
4619 const customHandler = player.config.listeners[customHandlerKey];
4620 const hasCustomHandler = is.function(customHandler);
4621 let returned = true;
4622
4623 // Execute custom handler
4624 if (hasCustomHandler) {
4625 returned = customHandler.call(player, event);
4626 }
4627
4628 // Only call default handler if not prevented in custom handler
4629 if (returned !== false && is.function(defaultHandler)) {
4630 defaultHandler.call(player, event);
4631 }
4632 });
4633 // Trigger custom and default handlers
4634 _defineProperty$1(this, "bind", (element, type, defaultHandler, customHandlerKey, passive = true) => {
4635 const {
4636 player
4637 } = this;
4638 const customHandler = player.config.listeners[customHandlerKey];
4639 const hasCustomHandler = is.function(customHandler);
4640 on.call(player, element, type, event => this.proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler);
4641 });
4642 // Listen for control events
4643 _defineProperty$1(this, "controls", () => {
4644 const {
4645 player
4646 } = this;
4647 const {
4648 elements
4649 } = player;
4650 // IE doesn't support input event, so we fallback to change
4651 const inputEvent = browser.isIE ? 'change' : 'input';
4652
4653 // Play/pause toggle
4654 if (elements.buttons.play) {
4655 Array.from(elements.buttons.play).forEach(button => {
4656 this.bind(button, 'click', () => {
4657 silencePromise(player.togglePlay());
4658 }, 'play');
4659 });
4660 }
4661
4662 // Pause
4663 this.bind(elements.buttons.restart, 'click', player.restart, 'restart');
4664
4665 // Rewind
4666 this.bind(elements.buttons.rewind, 'click', () => {
4667 // Record seek time so we can prevent hiding controls for a few seconds after rewind
4668 player.lastSeekTime = Date.now();
4669 player.rewind();
4670 }, 'rewind');
4671
4672 // Rewind
4673 this.bind(elements.buttons.fastForward, 'click', () => {
4674 // Record seek time so we can prevent hiding controls for a few seconds after fast forward
4675 player.lastSeekTime = Date.now();
4676 player.forward();
4677 }, 'fastForward');
4678
4679 // Mute toggle
4680 this.bind(elements.buttons.mute, 'click', () => {
4681 player.muted = !player.muted;
4682 }, 'mute');
4683
4684 // Captions toggle
4685 this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions());
4686
4687 // Download
4688 this.bind(elements.buttons.download, 'click', () => {
4689 triggerEvent.call(player, player.media, 'download');
4690 }, 'download');
4691
4692 // Fullscreen toggle
4693 this.bind(elements.buttons.fullscreen, 'click', () => {
4694 player.fullscreen.toggle();
4695 }, 'fullscreen');
4696
4697 // Picture-in-Picture
4698 this.bind(elements.buttons.pip, 'click', () => {
4699 player.pip = 'toggle';
4700 }, 'pip');
4701
4702 // Airplay
4703 this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay');
4704
4705 // Settings menu - click toggle
4706 this.bind(elements.buttons.settings, 'click', event => {
4707 // Prevent the document click listener closing the menu
4708 event.stopPropagation();
4709 event.preventDefault();
4710 controls.toggleMenu.call(player, event);
4711 }, null, false); // Can't be passive as we're preventing default
4712
4713 // Settings menu - keyboard toggle
4714 // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
4715 // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
4716 this.bind(elements.buttons.settings, 'keyup', event => {
4717 if (![' ', 'Enter'].includes(event.key)) {
4718 return;
4719 }
4720
4721 // Because return triggers a click anyway, all we need to do is set focus
4722 if (event.key === 'Enter') {
4723 controls.focusFirstMenuItem.call(player, null, true);
4724 return;
4725 }
4726
4727 // Prevent scroll
4728 event.preventDefault();
4729
4730 // Prevent playing video (Firefox)
4731 event.stopPropagation();
4732
4733 // Toggle menu
4734 controls.toggleMenu.call(player, event);
4735 }, null, false // Can't be passive as we're preventing default
4736 );
4737
4738 // Escape closes menu
4739 this.bind(elements.settings.menu, 'keydown', event => {
4740 if (event.key === 'Escape') {
4741 controls.toggleMenu.call(player, event);
4742 }
4743 });
4744
4745 // Set range input alternative "value", which matches the tooltip time (#954)
4746 this.bind(elements.inputs.seek, 'mousedown mousemove', event => {
4747 const rect = elements.progress.getBoundingClientRect();
4748 const percent = 100 / rect.width * (event.pageX - rect.left);
4749 event.currentTarget.setAttribute('seek-value', percent);
4750 });
4751
4752 // Pause while seeking
4753 this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
4754 const seek = event.currentTarget;
4755 const attribute = 'play-on-seeked';
4756 if (is.keyboardEvent(event) && !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
4757 return;
4758 }
4759
4760 // Record seek time so we can prevent hiding controls for a few seconds after seek
4761 player.lastSeekTime = Date.now();
4762
4763 // Was playing before?
4764 const play = seek.hasAttribute(attribute);
4765 // Done seeking
4766 const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);
4767
4768 // If we're done seeking and it was playing, resume playback
4769 if (play && done) {
4770 seek.removeAttribute(attribute);
4771 silencePromise(player.play());
4772 } else if (!done && player.playing) {
4773 seek.setAttribute(attribute, '');
4774 player.pause();
4775 }
4776 });
4777
4778 // Fix range inputs on iOS
4779 // Super weird iOS bug where after you interact with an <input type="range">,
4780 // it takes over further interactions on the page. This is a hack
4781 if (browser.isIos) {
4782 const inputs = getElements.call(player, 'input[type="range"]');
4783 Array.from(inputs).forEach(input => this.bind(input, inputEvent, event => repaint(event.target)));
4784 }
4785
4786 // Seek
4787 this.bind(elements.inputs.seek, inputEvent, event => {
4788 const seek = event.currentTarget;
4789 // If it exists, use seek-value instead of "value" for consistency with tooltip time (#954)
4790 let seekTo = seek.getAttribute('seek-value');
4791 if (is.empty(seekTo)) {
4792 seekTo = seek.value;
4793 }
4794 seek.removeAttribute('seek-value');
4795 player.currentTime = seekTo / seek.max * player.duration;
4796 }, 'seek');
4797
4798 // Seek tooltip
4799 this.bind(elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(player, event));
4800
4801 // Preview thumbnails plugin
4802 // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this
4803 this.bind(elements.progress, 'mousemove touchmove', event => {
4804 const {
4805 previewThumbnails
4806 } = player;
4807 if (previewThumbnails && previewThumbnails.loaded) {
4808 previewThumbnails.startMove(event);
4809 }
4810 });
4811
4812 // Hide thumbnail preview - on mouse click, mouse leave, and video play/seek. All four are required, e.g., for buffering
4813 this.bind(elements.progress, 'mouseleave touchend click', () => {
4814 const {
4815 previewThumbnails
4816 } = player;
4817 if (previewThumbnails && previewThumbnails.loaded) {
4818 previewThumbnails.endMove(false, true);
4819 }
4820 });
4821
4822 // Show scrubbing preview
4823 this.bind(elements.progress, 'mousedown touchstart', event => {
4824 const {
4825 previewThumbnails
4826 } = player;
4827 if (previewThumbnails && previewThumbnails.loaded) {
4828 previewThumbnails.startScrubbing(event);
4829 }
4830 });
4831 this.bind(elements.progress, 'mouseup touchend', event => {
4832 const {
4833 previewThumbnails
4834 } = player;
4835 if (previewThumbnails && previewThumbnails.loaded) {
4836 previewThumbnails.endScrubbing(event);
4837 }
4838 });
4839
4840 // Polyfill for lower fill in <input type="range"> for webkit
4841 if (browser.isWebKit) {
4842 Array.from(getElements.call(player, 'input[type="range"]')).forEach(element => {
4843 this.bind(element, 'input', event => controls.updateRangeFill.call(player, event.target));
4844 });
4845 }
4846
4847 // Current time invert
4848 // Only if one time element is used for both currentTime and duration
4849 if (player.config.toggleInvert && !is.element(elements.display.duration)) {
4850 this.bind(elements.display.currentTime, 'click', () => {
4851 // Do nothing if we're at the start
4852 if (player.currentTime === 0) {
4853 return;
4854 }
4855 player.config.invertTime = !player.config.invertTime;
4856 controls.timeUpdate.call(player);
4857 });
4858 }
4859
4860 // Volume
4861 this.bind(elements.inputs.volume, inputEvent, event => {
4862 player.volume = event.target.value;
4863 }, 'volume');
4864
4865 // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
4866 this.bind(elements.controls, 'mouseenter mouseleave', event => {
4867 elements.controls.hover = !player.touch && event.type === 'mouseenter';
4868 });
4869
4870 // Also update controls.hover state for any non-player children of fullscreen element (as above)
4871 if (elements.fullscreen) {
4872 Array.from(elements.fullscreen.children).filter(c => !c.contains(elements.container)).forEach(child => {
4873 this.bind(child, 'mouseenter mouseleave', event => {
4874 if (elements.controls) {
4875 elements.controls.hover = !player.touch && event.type === 'mouseenter';
4876 }
4877 });
4878 });
4879 }
4880
4881 // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
4882 this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
4883 elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
4884 });
4885
4886 // Show controls when they receive focus (e.g., when using keyboard tab key)
4887 this.bind(elements.controls, 'focusin', () => {
4888 const {
4889 config,
4890 timers
4891 } = player;
4892
4893 // Skip transition to prevent focus from scrolling the parent element
4894 toggleClass(elements.controls, config.classNames.noTransition, true);
4895
4896 // Toggle
4897 ui.toggleControls.call(player, true);
4898
4899 // Restore transition
4900 setTimeout(() => {
4901 toggleClass(elements.controls, config.classNames.noTransition, false);
4902 }, 0);
4903
4904 // Delay a little more for mouse users
4905 const delay = this.touch ? 3000 : 4000;
4906
4907 // Clear timer
4908 clearTimeout(timers.controls);
4909
4910 // Hide again after delay
4911 timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);
4912 });
4913
4914 // Mouse wheel for volume
4915 this.bind(elements.inputs.volume, 'wheel', event => {
4916 // Detect "natural" scroll - supported on OS X Safari only
4917 // Other browsers on OS X will be inverted until support improves
4918 const inverted = event.webkitDirectionInvertedFromDevice;
4919 // Get delta from event. Invert if `inverted` is true
4920 const [x, y] = [event.deltaX, -event.deltaY].map(value => inverted ? -value : value);
4921 // Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)
4922 const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);
4923
4924 // Change the volume by 2%
4925 player.increaseVolume(direction / 50);
4926
4927 // Don't break page scrolling at max and min
4928 const {
4929 volume
4930 } = player.media;
4931 if (direction === 1 && volume < 1 || direction === -1 && volume > 0) {
4932 event.preventDefault();
4933 }
4934 }, 'volume', false);
4935 });
4936 this.player = _player;
4937 this.lastKey = null;
4938 this.focusTimer = null;
4939 this.lastKeyDown = null;
4940 this.handleKey = this.handleKey.bind(this);
4941 this.toggleMenu = this.toggleMenu.bind(this);
4942 this.firstTouch = this.firstTouch.bind(this);
4943 }
4944
4945 // Handle key presses
4946 handleKey(event) {
4947 const {
4948 player
4949 } = this;
4950 const {
4951 elements
4952 } = player;
4953 const {
4954 key,
4955 type,
4956 altKey,
4957 ctrlKey,
4958 metaKey,
4959 shiftKey
4960 } = event;
4961 const pressed = type === 'keydown';
4962 const repeat = pressed && key === this.lastKey;
4963
4964 // Bail if a modifier key is set
4965 if (altKey || ctrlKey || metaKey || shiftKey) {
4966 return;
4967 }
4968
4969 // If the event is bubbled from the media element
4970 // Firefox doesn't get the key for whatever reason
4971 if (!key) {
4972 return;
4973 }
4974
4975 // Seek by increment
4976 const seekByIncrement = increment => {
4977 // Divide the max duration into 10th's and times by the number value
4978 player.currentTime = player.duration / 10 * increment;
4979 };
4980
4981 // Handle the key on keydown
4982 // Reset on keyup
4983 if (pressed) {
4984 // Check focused element
4985 // and if the focused element is not editable (e.g. text input)
4986 // and any that accept key input http://webaim.org/techniques/keyboard/
4987 const focused = document.activeElement;
4988 if (is.element(focused)) {
4989 const {
4990 editable
4991 } = player.config.selectors;
4992 const {
4993 seek
4994 } = elements.inputs;
4995 if (focused !== seek && matches(focused, editable)) {
4996 return;
4997 }
4998 if (event.key === ' ' && matches(focused, 'button, [role^="menuitem"]')) {
4999 return;
5000 }
5001 }
5002
5003 // Which keys should we prevent default
5004 const preventDefault = [' ', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'c', 'f', 'k', 'l', 'm'];
5005
5006 // If the key is found prevent default (e.g. prevent scrolling for arrows)
5007 if (preventDefault.includes(key)) {
5008 event.preventDefault();
5009 event.stopPropagation();
5010 }
5011 switch (key) {
5012 case '0':
5013 case '1':
5014 case '2':
5015 case '3':
5016 case '4':
5017 case '5':
5018 case '6':
5019 case '7':
5020 case '8':
5021 case '9':
5022 if (!repeat) {
5023 seekByIncrement(parseInt(key, 10));
5024 }
5025 break;
5026 case ' ':
5027 case 'k':
5028 if (!repeat) {
5029 silencePromise(player.togglePlay());
5030 }
5031 break;
5032 case 'ArrowUp':
5033 player.increaseVolume(0.1);
5034 break;
5035 case 'ArrowDown':
5036 player.decreaseVolume(0.1);
5037 break;
5038 case 'm':
5039 if (!repeat) {
5040 player.muted = !player.muted;
5041 }
5042 break;
5043 case 'ArrowRight':
5044 player.forward();
5045 break;
5046 case 'ArrowLeft':
5047 player.rewind();
5048 break;
5049 case 'f':
5050 player.fullscreen.toggle();
5051 break;
5052 case 'c':
5053 if (!repeat) {
5054 player.toggleCaptions();
5055 }
5056 break;
5057 case 'l':
5058 player.loop = !player.loop;
5059 break;
5060 }
5061
5062 // Escape is handle natively when in full screen
5063 // So we only need to worry about non native
5064 if (key === 'Escape' && !player.fullscreen.usingNative && player.fullscreen.active) {
5065 player.fullscreen.toggle();
5066 }
5067
5068 // Store last key for next cycle
5069 this.lastKey = key;
5070 } else {
5071 this.lastKey = null;
5072 }
5073 }
5074
5075 // Toggle menu
5076 toggleMenu(event) {
5077 controls.toggleMenu.call(this.player, event);
5078 }
5079 }
5080
5081 var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
5082
5083 function createCommonjsModule(fn, module) {
5084 return module = { exports: {} }, fn(module, module.exports), module.exports;
5085 }
5086
5087 var loadjs_umd = createCommonjsModule(function (module, exports) {
5088 (function (root, factory) {
5089 {
5090 module.exports = factory();
5091 }
5092 })(commonjsGlobal, function () {
5093 /**
5094 * Global dependencies.
5095 * @global {Object} document - DOM
5096 */
5097
5098 var devnull = function () {},
5099 bundleIdCache = {},
5100 bundleResultCache = {},
5101 bundleCallbackQueue = {};
5102
5103 /**
5104 * Subscribe to bundle load event.
5105 * @param {string[]} bundleIds - Bundle ids
5106 * @param {Function} callbackFn - The callback function
5107 */
5108 function subscribe(bundleIds, callbackFn) {
5109 // listify
5110 bundleIds = bundleIds.push ? bundleIds : [bundleIds];
5111 var depsNotFound = [],
5112 i = bundleIds.length,
5113 numWaiting = i,
5114 fn,
5115 bundleId,
5116 r,
5117 q;
5118
5119 // define callback function
5120 fn = function (bundleId, pathsNotFound) {
5121 if (pathsNotFound.length) depsNotFound.push(bundleId);
5122 numWaiting--;
5123 if (!numWaiting) callbackFn(depsNotFound);
5124 };
5125
5126 // register callback
5127 while (i--) {
5128 bundleId = bundleIds[i];
5129
5130 // execute callback if in result cache
5131 r = bundleResultCache[bundleId];
5132 if (r) {
5133 fn(bundleId, r);
5134 continue;
5135 }
5136
5137 // add to callback queue
5138 q = bundleCallbackQueue[bundleId] = bundleCallbackQueue[bundleId] || [];
5139 q.push(fn);
5140 }
5141 }
5142
5143 /**
5144 * Publish bundle load event.
5145 * @param {string} bundleId - Bundle id
5146 * @param {string[]} pathsNotFound - List of files not found
5147 */
5148 function publish(bundleId, pathsNotFound) {
5149 // exit if id isn't defined
5150 if (!bundleId) return;
5151 var q = bundleCallbackQueue[bundleId];
5152
5153 // cache result
5154 bundleResultCache[bundleId] = pathsNotFound;
5155
5156 // exit if queue is empty
5157 if (!q) return;
5158
5159 // empty callback queue
5160 while (q.length) {
5161 q[0](bundleId, pathsNotFound);
5162 q.splice(0, 1);
5163 }
5164 }
5165
5166 /**
5167 * Execute callbacks.
5168 * @param {Object or Function} args - The callback args
5169 * @param {string[]} depsNotFound - List of dependencies not found
5170 */
5171 function executeCallbacks(args, depsNotFound) {
5172 // accept function as argument
5173 if (args.call) args = {
5174 success: args
5175 };
5176
5177 // success and error callbacks
5178 if (depsNotFound.length) (args.error || devnull)(depsNotFound);else (args.success || devnull)(args);
5179 }
5180
5181 /**
5182 * Load individual file.
5183 * @param {string} path - The file path
5184 * @param {Function} callbackFn - The callback function
5185 */
5186 function loadFile(path, callbackFn, args, numTries) {
5187 var doc = document,
5188 async = args.async,
5189 maxTries = (args.numRetries || 0) + 1,
5190 beforeCallbackFn = args.before || devnull,
5191 pathname = path.replace(/[\?|#].*$/, ''),
5192 pathStripped = path.replace(/^(css|img)!/, ''),
5193 isLegacyIECss,
5194 e;
5195 numTries = numTries || 0;
5196 if (/(^css!|\.css$)/.test(pathname)) {
5197 // css
5198 e = doc.createElement('link');
5199 e.rel = 'stylesheet';
5200 e.href = pathStripped;
5201
5202 // tag IE9+
5203 isLegacyIECss = 'hideFocus' in e;
5204
5205 // use preload in IE Edge (to detect load errors)
5206 if (isLegacyIECss && e.relList) {
5207 isLegacyIECss = 0;
5208 e.rel = 'preload';
5209 e.as = 'style';
5210 }
5211 } else if (/(^img!|\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {
5212 // image
5213 e = doc.createElement('img');
5214 e.src = pathStripped;
5215 } else {
5216 // javascript
5217 e = doc.createElement('script');
5218 e.src = path;
5219 e.async = async === undefined ? true : async;
5220 }
5221 e.onload = e.onerror = e.onbeforeload = function (ev) {
5222 var result = ev.type[0];
5223
5224 // treat empty stylesheets as failures to get around lack of onerror
5225 // support in IE9-11
5226 if (isLegacyIECss) {
5227 try {
5228 if (!e.sheet.cssText.length) result = 'e';
5229 } catch (x) {
5230 // sheets objects created from load errors don't allow access to
5231 // `cssText` (unless error is Code:18 SecurityError)
5232 if (x.code != 18) result = 'e';
5233 }
5234 }
5235
5236 // handle retries in case of load failure
5237 if (result == 'e') {
5238 // increment counter
5239 numTries += 1;
5240
5241 // exit function and try again
5242 if (numTries < maxTries) {
5243 return loadFile(path, callbackFn, args, numTries);
5244 }
5245 } else if (e.rel == 'preload' && e.as == 'style') {
5246 // activate preloaded stylesheets
5247 return e.rel = 'stylesheet'; // jshint ignore:line
5248 }
5249
5250 // execute callback
5251 callbackFn(path, result, ev.defaultPrevented);
5252 };
5253
5254 // add to document (unless callback returns `false`)
5255 if (beforeCallbackFn(path, e) !== false) doc.head.appendChild(e);
5256 }
5257
5258 /**
5259 * Load multiple files.
5260 * @param {string[]} paths - The file paths
5261 * @param {Function} callbackFn - The callback function
5262 */
5263 function loadFiles(paths, callbackFn, args) {
5264 // listify paths
5265 paths = paths.push ? paths : [paths];
5266 var numWaiting = paths.length,
5267 x = numWaiting,
5268 pathsNotFound = [],
5269 fn,
5270 i;
5271
5272 // define callback function
5273 fn = function (path, result, defaultPrevented) {
5274 // handle error
5275 if (result == 'e') pathsNotFound.push(path);
5276
5277 // handle beforeload event. If defaultPrevented then that means the load
5278 // will be blocked (ex. Ghostery/ABP on Safari)
5279 if (result == 'b') {
5280 if (defaultPrevented) pathsNotFound.push(path);else return;
5281 }
5282 numWaiting--;
5283 if (!numWaiting) callbackFn(pathsNotFound);
5284 };
5285
5286 // load scripts
5287 for (i = 0; i < x; i++) loadFile(paths[i], fn, args);
5288 }
5289
5290 /**
5291 * Initiate script load and register bundle.
5292 * @param {(string|string[])} paths - The file paths
5293 * @param {(string|Function|Object)} [arg1] - The (1) bundleId or (2) success
5294 * callback or (3) object literal with success/error arguments, numRetries,
5295 * etc.
5296 * @param {(Function|Object)} [arg2] - The (1) success callback or (2) object
5297 * literal with success/error arguments, numRetries, etc.
5298 */
5299 function loadjs(paths, arg1, arg2) {
5300 var bundleId, args;
5301
5302 // bundleId (if string)
5303 if (arg1 && arg1.trim) bundleId = arg1;
5304
5305 // args (default is {})
5306 args = (bundleId ? arg2 : arg1) || {};
5307
5308 // throw error if bundle is already defined
5309 if (bundleId) {
5310 if (bundleId in bundleIdCache) {
5311 throw "LoadJS";
5312 } else {
5313 bundleIdCache[bundleId] = true;
5314 }
5315 }
5316 function loadFn(resolve, reject) {
5317 loadFiles(paths, function (pathsNotFound) {
5318 // execute callbacks
5319 executeCallbacks(args, pathsNotFound);
5320
5321 // resolve Promise
5322 if (resolve) {
5323 executeCallbacks({
5324 success: resolve,
5325 error: reject
5326 }, pathsNotFound);
5327 }
5328
5329 // publish bundle load event
5330 publish(bundleId, pathsNotFound);
5331 }, args);
5332 }
5333 if (args.returnPromise) return new Promise(loadFn);else loadFn();
5334 }
5335
5336 /**
5337 * Execute callbacks when dependencies have been satisfied.
5338 * @param {(string|string[])} deps - List of bundle ids
5339 * @param {Object} args - success/error arguments
5340 */
5341 loadjs.ready = function ready(deps, args) {
5342 // subscribe to bundle load event
5343 subscribe(deps, function (depsNotFound) {
5344 // execute callbacks
5345 executeCallbacks(args, depsNotFound);
5346 });
5347 return loadjs;
5348 };
5349
5350 /**
5351 * Manually satisfy bundle dependencies.
5352 * @param {string} bundleId - The bundle id
5353 */
5354 loadjs.done = function done(bundleId) {
5355 publish(bundleId, []);
5356 };
5357
5358 /**
5359 * Reset loadjs dependencies statuses
5360 */
5361 loadjs.reset = function reset() {
5362 bundleIdCache = {};
5363 bundleResultCache = {};
5364 bundleCallbackQueue = {};
5365 };
5366
5367 /**
5368 * Determine if bundle has already been defined
5369 * @param String} bundleId - The bundle id
5370 */
5371 loadjs.isDefined = function isDefined(bundleId) {
5372 return bundleId in bundleIdCache;
5373 };
5374
5375 // export
5376 return loadjs;
5377 });
5378 });
5379
5380 // ==========================================================================
5381 function loadScript(url) {
5382 return new Promise((resolve, reject) => {
5383 loadjs_umd(url, {
5384 success: resolve,
5385 error: reject
5386 });
5387 });
5388 }
5389
5390 // ==========================================================================
5391
5392 // Parse Vimeo ID from URL
5393 function parseId$1(url) {
5394 if (is.empty(url)) {
5395 return null;
5396 }
5397 if (is.number(Number(url))) {
5398 return url;
5399 }
5400 const regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
5401 return url.match(regex) ? RegExp.$2 : url;
5402 }
5403
5404 // Try to extract a hash for private videos from the URL
5405 function parseHash(url) {
5406 /* This regex matches a hexadecimal hash if given in any of these forms:
5407 * - [https://player.]vimeo.com/video/{id}/{hash}[?params]
5408 * - [https://player.]vimeo.com/video/{id}?h={hash}[&params]
5409 * - [https://player.]vimeo.com/video/{id}?[params]&h={hash}
5410 * - video/{id}/{hash}
5411 * If matched, the hash is available in capture group 4
5412 */
5413 const regex = /^.*(vimeo.com\/|video\/)(\d+)(\?.*&*h=|\/)+([\d,a-f]+)/;
5414 const found = url.match(regex);
5415 return found && found.length === 5 ? found[4] : null;
5416 }
5417
5418 // Set playback state and trigger change (only on actual change)
5419 function assurePlaybackState$1(play) {
5420 if (play && !this.embed.hasPlayed) {
5421 this.embed.hasPlayed = true;
5422 }
5423 if (this.media.paused === play) {
5424 this.media.paused = !play;
5425 triggerEvent.call(this, this.media, play ? 'play' : 'pause');
5426 }
5427 }
5428 const vimeo = {
5429 setup() {
5430 const player = this;
5431
5432 // Add embed class for responsive
5433 toggleClass(player.elements.wrapper, player.config.classNames.embed, true);
5434
5435 // Set speed options from config
5436 player.options.speed = player.config.speed.options;
5437
5438 // Set intial ratio
5439 setAspectRatio.call(player);
5440
5441 // Load the SDK if not already
5442 if (!is.object(window.Vimeo)) {
5443 loadScript(player.config.urls.vimeo.sdk).then(() => {
5444 vimeo.ready.call(player);
5445 }).catch(error => {
5446 player.debug.warn('Vimeo SDK (player.js) failed to load', error);
5447 });
5448 } else {
5449 vimeo.ready.call(player);
5450 }
5451 },
5452 // API Ready
5453 ready() {
5454 const player = this;
5455 const config = player.config.vimeo;
5456 const {
5457 premium,
5458 referrerPolicy,
5459 ...frameParams
5460 } = config;
5461 // Get the source URL or ID
5462 let source = player.media.getAttribute('src');
5463 let hash = '';
5464 // Get from <div> if needed
5465 if (is.empty(source)) {
5466 source = player.media.getAttribute(player.config.attributes.embed.id);
5467 // hash can also be set as attribute on the <div>
5468 hash = player.media.getAttribute(player.config.attributes.embed.hash);
5469 } else {
5470 hash = parseHash(source);
5471 }
5472 const hashParam = hash ? {
5473 h: hash
5474 } : {};
5475
5476 // If the owner has a pro or premium account then we can hide controls etc
5477 if (premium) {
5478 Object.assign(frameParams, {
5479 controls: false,
5480 sidedock: false
5481 });
5482 }
5483
5484 // Get Vimeo params for the iframe
5485 const params = buildUrlParams({
5486 loop: player.config.loop.active,
5487 autoplay: player.autoplay,
5488 muted: player.muted,
5489 gesture: 'media',
5490 playsinline: player.config.playsinline,
5491 // hash has to be added to iframe-URL
5492 ...hashParam,
5493 ...frameParams
5494 });
5495 const id = parseId$1(source);
5496 // Build an iframe
5497 const iframe = createElement('iframe');
5498 const src = format(player.config.urls.vimeo.iframe, id, params);
5499 iframe.setAttribute('src', src);
5500 iframe.setAttribute('allowfullscreen', '');
5501 iframe.setAttribute('allow', ['autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media', 'accelerometer', 'gyroscope'].join('; '));
5502
5503 // Set the referrer policy if required
5504 if (!is.empty(referrerPolicy)) {
5505 iframe.setAttribute('referrerPolicy', referrerPolicy);
5506 }
5507
5508 // Inject the package
5509 if (premium || !config.customControls) {
5510 iframe.setAttribute('data-poster', player.poster);
5511 player.media = replaceElement(iframe, player.media);
5512 } else {
5513 const wrapper = createElement('div', {
5514 class: player.config.classNames.embedContainer,
5515 'data-poster': player.poster
5516 });
5517 wrapper.appendChild(iframe);
5518 player.media = replaceElement(wrapper, player.media);
5519 }
5520
5521 // Get poster image
5522 if (!config.customControls) {
5523 fetch(format(player.config.urls.vimeo.api, src)).then(response => {
5524 if (is.empty(response) || !response.thumbnail_url) {
5525 return;
5526 }
5527
5528 // Set and show poster
5529 ui.setPoster.call(player, response.thumbnail_url).catch(() => {});
5530 });
5531 }
5532
5533 // Setup instance
5534 // https://github.com/vimeo/player.js
5535 player.embed = new window.Vimeo.Player(iframe, {
5536 autopause: player.config.autopause,
5537 muted: player.muted
5538 });
5539 player.media.paused = true;
5540 player.media.currentTime = 0;
5541
5542 // Disable native text track rendering
5543 if (player.supported.ui) {
5544 player.embed.disableTextTrack();
5545 }
5546
5547 // Create a faux HTML5 API using the Vimeo API
5548 player.media.play = () => {
5549 assurePlaybackState$1.call(player, true);
5550 return player.embed.play();
5551 };
5552 player.media.pause = () => {
5553 assurePlaybackState$1.call(player, false);
5554 return player.embed.pause();
5555 };
5556 player.media.stop = () => {
5557 player.pause();
5558 player.currentTime = 0;
5559 };
5560
5561 // Seeking
5562 let {
5563 currentTime
5564 } = player.media;
5565 Object.defineProperty(player.media, 'currentTime', {
5566 get() {
5567 return currentTime;
5568 },
5569 set(time) {
5570 // Vimeo will automatically play on seek if the video hasn't been played before
5571
5572 // Get current paused state and volume etc
5573 const {
5574 embed,
5575 media,
5576 paused,
5577 volume
5578 } = player;
5579 const restorePause = paused && !embed.hasPlayed;
5580
5581 // Set seeking state and trigger event
5582 media.seeking = true;
5583 triggerEvent.call(player, media, 'seeking');
5584
5585 // If paused, mute until seek is complete
5586 Promise.resolve(restorePause && embed.setVolume(0))
5587 // Seek
5588 .then(() => embed.setCurrentTime(time))
5589 // Restore paused
5590 .then(() => restorePause && embed.pause())
5591 // Restore volume
5592 .then(() => restorePause && embed.setVolume(volume)).catch(() => {
5593 // Do nothing
5594 });
5595 }
5596 });
5597
5598 // Playback speed
5599 let speed = player.config.speed.selected;
5600 Object.defineProperty(player.media, 'playbackRate', {
5601 get() {
5602 return speed;
5603 },
5604 set(input) {
5605 player.embed.setPlaybackRate(input).then(() => {
5606 speed = input;
5607 triggerEvent.call(player, player.media, 'ratechange');
5608 }).catch(() => {
5609 // Cannot set Playback Rate, Video is probably not on Pro account
5610 player.options.speed = [1];
5611 });
5612 }
5613 });
5614
5615 // Volume
5616 let {
5617 volume
5618 } = player.config;
5619 Object.defineProperty(player.media, 'volume', {
5620 get() {
5621 return volume;
5622 },
5623 set(input) {
5624 player.embed.setVolume(input).then(() => {
5625 volume = input;
5626 triggerEvent.call(player, player.media, 'volumechange');
5627 });
5628 }
5629 });
5630
5631 // Muted
5632 let {
5633 muted
5634 } = player.config;
5635 Object.defineProperty(player.media, 'muted', {
5636 get() {
5637 return muted;
5638 },
5639 set(input) {
5640 const toggle = is.boolean(input) ? input : false;
5641 player.embed.setMuted(toggle ? true : player.config.muted).then(() => {
5642 muted = toggle;
5643 triggerEvent.call(player, player.media, 'volumechange');
5644 });
5645 }
5646 });
5647
5648 // Loop
5649 let {
5650 loop
5651 } = player.config;
5652 Object.defineProperty(player.media, 'loop', {
5653 get() {
5654 return loop;
5655 },
5656 set(input) {
5657 const toggle = is.boolean(input) ? input : player.config.loop.active;
5658 player.embed.setLoop(toggle).then(() => {
5659 loop = toggle;
5660 });
5661 }
5662 });
5663
5664 // Source
5665 let currentSrc;
5666 player.embed.getVideoUrl().then(value => {
5667 currentSrc = value;
5668 controls.setDownloadUrl.call(player);
5669 }).catch(error => {
5670 this.debug.warn(error);
5671 });
5672 Object.defineProperty(player.media, 'currentSrc', {
5673 get() {
5674 return currentSrc;
5675 }
5676 });
5677
5678 // Ended
5679 Object.defineProperty(player.media, 'ended', {
5680 get() {
5681 return player.currentTime === player.duration;
5682 }
5683 });
5684
5685 // Set aspect ratio based on video size
5686 Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => {
5687 const [width, height] = dimensions;
5688 player.embed.ratio = roundAspectRatio(width, height);
5689 setAspectRatio.call(this);
5690 });
5691
5692 // Set autopause
5693 player.embed.setAutopause(player.config.autopause).then(state => {
5694 player.config.autopause = state;
5695 });
5696
5697 // Get title
5698 player.embed.getVideoTitle().then(title => {
5699 player.config.title = title;
5700 ui.setTitle.call(this);
5701 });
5702
5703 // Get current time
5704 player.embed.getCurrentTime().then(value => {
5705 currentTime = value;
5706 triggerEvent.call(player, player.media, 'timeupdate');
5707 });
5708
5709 // Get duration
5710 player.embed.getDuration().then(value => {
5711 player.media.duration = value;
5712 triggerEvent.call(player, player.media, 'durationchange');
5713 });
5714
5715 // Get captions
5716 player.embed.getTextTracks().then(tracks => {
5717 player.media.textTracks = tracks;
5718 captions.setup.call(player);
5719 });
5720 player.embed.on('cuechange', ({
5721 cues = []
5722 }) => {
5723 const strippedCues = cues.map(cue => stripHTML(cue.text));
5724 captions.updateCues.call(player, strippedCues);
5725 });
5726 player.embed.on('loaded', () => {
5727 // Assure state and events are updated on autoplay
5728 player.embed.getPaused().then(paused => {
5729 assurePlaybackState$1.call(player, !paused);
5730 if (!paused) {
5731 triggerEvent.call(player, player.media, 'playing');
5732 }
5733 });
5734 if (is.element(player.embed.element) && player.supported.ui) {
5735 const frame = player.embed.element;
5736
5737 // Fix keyboard focus issues
5738 // https://github.com/sampotts/plyr/issues/317
5739 frame.setAttribute('tabindex', -1);
5740 }
5741 });
5742 player.embed.on('bufferstart', () => {
5743 triggerEvent.call(player, player.media, 'waiting');
5744 });
5745 player.embed.on('bufferend', () => {
5746 triggerEvent.call(player, player.media, 'playing');
5747 });
5748 player.embed.on('play', () => {
5749 assurePlaybackState$1.call(player, true);
5750 triggerEvent.call(player, player.media, 'playing');
5751 });
5752 player.embed.on('pause', () => {
5753 assurePlaybackState$1.call(player, false);
5754 });
5755 player.embed.on('timeupdate', data => {
5756 player.media.seeking = false;
5757 currentTime = data.seconds;
5758 triggerEvent.call(player, player.media, 'timeupdate');
5759 });
5760 player.embed.on('progress', data => {
5761 player.media.buffered = data.percent;
5762 triggerEvent.call(player, player.media, 'progress');
5763
5764 // Check all loaded
5765 if (parseInt(data.percent, 10) === 1) {
5766 triggerEvent.call(player, player.media, 'canplaythrough');
5767 }
5768
5769 // Get duration as if we do it before load, it gives an incorrect value
5770 // https://github.com/sampotts/plyr/issues/891
5771 player.embed.getDuration().then(value => {
5772 if (value !== player.media.duration) {
5773 player.media.duration = value;
5774 triggerEvent.call(player, player.media, 'durationchange');
5775 }
5776 });
5777 });
5778 player.embed.on('seeked', () => {
5779 player.media.seeking = false;
5780 triggerEvent.call(player, player.media, 'seeked');
5781 });
5782 player.embed.on('ended', () => {
5783 player.media.paused = true;
5784 triggerEvent.call(player, player.media, 'ended');
5785 });
5786 player.embed.on('error', detail => {
5787 player.media.error = detail;
5788 triggerEvent.call(player, player.media, 'error');
5789 });
5790
5791 // Rebuild UI
5792 if (config.customControls) {
5793 setTimeout(() => ui.build.call(player), 0);
5794 }
5795 }
5796 };
5797
5798 // ==========================================================================
5799
5800 // Parse YouTube ID from URL
5801 function parseId(url) {
5802 if (is.empty(url)) {
5803 return null;
5804 }
5805 const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
5806 return url.match(regex) ? RegExp.$2 : url;
5807 }
5808
5809 // Set playback state and trigger change (only on actual change)
5810 function assurePlaybackState(play) {
5811 if (play && !this.embed.hasPlayed) {
5812 this.embed.hasPlayed = true;
5813 }
5814 if (this.media.paused === play) {
5815 this.media.paused = !play;
5816 triggerEvent.call(this, this.media, play ? 'play' : 'pause');
5817 }
5818 }
5819 function getHost(config) {
5820 if (config.noCookie) {
5821 return 'https://www.youtube-nocookie.com';
5822 }
5823 if (window.location.protocol === 'http:') {
5824 return 'http://www.youtube.com';
5825 }
5826
5827 // Use YouTube's default
5828 return undefined;
5829 }
5830 const youtube = {
5831 setup() {
5832 // Add embed class for responsive
5833 toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
5834
5835 // Setup API
5836 if (is.object(window.YT) && is.function(window.YT.Player)) {
5837 youtube.ready.call(this);
5838 } else {
5839 // Reference current global callback
5840 const callback = window.onYouTubeIframeAPIReady;
5841
5842 // Set callback to process queue
5843 window.onYouTubeIframeAPIReady = () => {
5844 // Call global callback if set
5845 if (is.function(callback)) {
5846 callback();
5847 }
5848 youtube.ready.call(this);
5849 };
5850
5851 // Load the SDK
5852 loadScript(this.config.urls.youtube.sdk).catch(error => {
5853 this.debug.warn('YouTube API failed to load', error);
5854 });
5855 }
5856 },
5857 // Get the media title
5858 getTitle(videoId) {
5859 const url = format(this.config.urls.youtube.api, videoId);
5860 fetch(url).then(data => {
5861 if (is.object(data)) {
5862 const {
5863 title,
5864 height,
5865 width
5866 } = data;
5867
5868 // Set title
5869 this.config.title = title;
5870 ui.setTitle.call(this);
5871
5872 // Set aspect ratio
5873 this.embed.ratio = roundAspectRatio(width, height);
5874 }
5875 setAspectRatio.call(this);
5876 }).catch(() => {
5877 // Set aspect ratio
5878 setAspectRatio.call(this);
5879 });
5880 },
5881 // API ready
5882 ready() {
5883 const player = this;
5884 const config = player.config.youtube;
5885 // Ignore already setup (race condition)
5886 const currentId = player.media && player.media.getAttribute('id');
5887 if (!is.empty(currentId) && currentId.startsWith('youtube-')) {
5888 return;
5889 }
5890
5891 // Get the source URL or ID
5892 let source = player.media.getAttribute('src');
5893
5894 // Get from <div> if needed
5895 if (is.empty(source)) {
5896 source = player.media.getAttribute(this.config.attributes.embed.id);
5897 }
5898
5899 // Replace the <iframe> with a <div> due to YouTube API issues
5900 const videoId = parseId(source);
5901 const id = generateId(player.provider);
5902 // Replace media element
5903 const container = createElement('div', {
5904 id,
5905 'data-poster': config.customControls ? player.poster : undefined
5906 });
5907 player.media = replaceElement(container, player.media);
5908
5909 // Only load the poster when using custom controls
5910 if (config.customControls) {
5911 const posterSrc = s => `https://i.ytimg.com/vi/${videoId}/${s}default.jpg`;
5912
5913 // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide)
5914 loadImage(posterSrc('maxres'), 121) // Highest quality and un-padded
5915 .catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3
5916 .catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists
5917 .then(image => ui.setPoster.call(player, image.src)).then(src => {
5918 // If the image is padded, use background-size "cover" instead (like youtube does too with their posters)
5919 if (!src.includes('maxres')) {
5920 player.elements.poster.style.backgroundSize = 'cover';
5921 }
5922 }).catch(() => {});
5923 }
5924
5925 // Setup instance
5926 // https://developers.google.com/youtube/iframe_api_reference
5927 player.embed = new window.YT.Player(player.media, {
5928 videoId,
5929 host: getHost(config),
5930 playerVars: extend({}, {
5931 // Autoplay
5932 autoplay: player.config.autoplay ? 1 : 0,
5933 // iframe interface language
5934 hl: player.config.hl,
5935 // Only show controls if not fully supported or opted out
5936 controls: player.supported.ui && config.customControls ? 0 : 1,
5937 // Disable keyboard as we handle it
5938 disablekb: 1,
5939 // Allow iOS inline playback
5940 playsinline: player.config.playsinline && !player.config.fullscreen.iosNative ? 1 : 0,
5941 // Captions are flaky on YouTube
5942 cc_load_policy: player.captions.active ? 1 : 0,
5943 cc_lang_pref: player.config.captions.language,
5944 // Tracking for stats
5945 widget_referrer: window ? window.location.href : null
5946 }, config),
5947 events: {
5948 onError(event) {
5949 // YouTube may fire onError twice, so only handle it once
5950 if (!player.media.error) {
5951 const code = event.data;
5952 // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
5953 const message = {
5954 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.',
5955 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.',
5956 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.',
5957 101: 'The owner of the requested video does not allow it to be played in embedded players.',
5958 150: 'The owner of the requested video does not allow it to be played in embedded players.'
5959 }[code] || 'An unknown error occurred';
5960 player.media.error = {
5961 code,
5962 message
5963 };
5964 triggerEvent.call(player, player.media, 'error');
5965 }
5966 },
5967 onPlaybackRateChange(event) {
5968 // Get the instance
5969 const instance = event.target;
5970
5971 // Get current speed
5972 player.media.playbackRate = instance.getPlaybackRate();
5973 triggerEvent.call(player, player.media, 'ratechange');
5974 },
5975 onReady(event) {
5976 // Bail if onReady has already been called. See issue #1108
5977 if (is.function(player.media.play)) {
5978 return;
5979 }
5980 // Get the instance
5981 const instance = event.target;
5982
5983 // Get the title
5984 youtube.getTitle.call(player, videoId);
5985
5986 // Create a faux HTML5 API using the YouTube API
5987 player.media.play = () => {
5988 assurePlaybackState.call(player, true);
5989 instance.playVideo();
5990 };
5991 player.media.pause = () => {
5992 assurePlaybackState.call(player, false);
5993 instance.pauseVideo();
5994 };
5995 player.media.stop = () => {
5996 instance.stopVideo();
5997 };
5998 player.media.duration = instance.getDuration();
5999 player.media.paused = true;
6000
6001 // Seeking
6002 player.media.currentTime = 0;
6003 Object.defineProperty(player.media, 'currentTime', {
6004 get() {
6005 return Number(instance.getCurrentTime());
6006 },
6007 set(time) {
6008 // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet).
6009 if (player.paused && !player.embed.hasPlayed) {
6010 player.embed.mute();
6011 }
6012
6013 // Set seeking state and trigger event
6014 player.media.seeking = true;
6015 triggerEvent.call(player, player.media, 'seeking');
6016
6017 // Seek after events sent
6018 instance.seekTo(time);
6019 }
6020 });
6021
6022 // Playback speed
6023 Object.defineProperty(player.media, 'playbackRate', {
6024 get() {
6025 return instance.getPlaybackRate();
6026 },
6027 set(input) {
6028 instance.setPlaybackRate(input);
6029 }
6030 });
6031
6032 // Volume
6033 let {
6034 volume
6035 } = player.config;
6036 Object.defineProperty(player.media, 'volume', {
6037 get() {
6038 return volume;
6039 },
6040 set(input) {
6041 volume = input;
6042 instance.setVolume(volume * 100);
6043 triggerEvent.call(player, player.media, 'volumechange');
6044 }
6045 });
6046
6047 // Muted
6048 let {
6049 muted
6050 } = player.config;
6051 Object.defineProperty(player.media, 'muted', {
6052 get() {
6053 return muted;
6054 },
6055 set(input) {
6056 const toggle = is.boolean(input) ? input : muted;
6057 muted = toggle;
6058 instance[toggle ? 'mute' : 'unMute']();
6059 instance.setVolume(volume * 100);
6060 triggerEvent.call(player, player.media, 'volumechange');
6061 }
6062 });
6063
6064 // Source
6065 Object.defineProperty(player.media, 'currentSrc', {
6066 get() {
6067 return instance.getVideoUrl();
6068 }
6069 });
6070
6071 // Ended
6072 Object.defineProperty(player.media, 'ended', {
6073 get() {
6074 return player.currentTime === player.duration;
6075 }
6076 });
6077
6078 // Get available speeds
6079 const speeds = instance.getAvailablePlaybackRates();
6080 // Filter based on config
6081 player.options.speed = speeds.filter(s => player.config.speed.options.includes(s));
6082
6083 // Set the tabindex to avoid focus entering iframe
6084 if (player.supported.ui && config.customControls) {
6085 player.media.setAttribute('tabindex', -1);
6086 }
6087 triggerEvent.call(player, player.media, 'timeupdate');
6088 triggerEvent.call(player, player.media, 'durationchange');
6089
6090 // Reset timer
6091 clearInterval(player.timers.buffering);
6092
6093 // Setup buffering
6094 player.timers.buffering = setInterval(() => {
6095 // Get loaded % from YouTube
6096 player.media.buffered = instance.getVideoLoadedFraction();
6097
6098 // Trigger progress only when we actually buffer something
6099 if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {
6100 triggerEvent.call(player, player.media, 'progress');
6101 }
6102
6103 // Set last buffer point
6104 player.media.lastBuffered = player.media.buffered;
6105
6106 // Bail if we're at 100%
6107 if (player.media.buffered === 1) {
6108 clearInterval(player.timers.buffering);
6109
6110 // Trigger event
6111 triggerEvent.call(player, player.media, 'canplaythrough');
6112 }
6113 }, 200);
6114
6115 // Rebuild UI
6116 if (config.customControls) {
6117 setTimeout(() => ui.build.call(player), 50);
6118 }
6119 },
6120 onStateChange(event) {
6121 // Get the instance
6122 const instance = event.target;
6123
6124 // Reset timer
6125 clearInterval(player.timers.playing);
6126 const seeked = player.media.seeking && [1, 2].includes(event.data);
6127 if (seeked) {
6128 // Unset seeking and fire seeked event
6129 player.media.seeking = false;
6130 triggerEvent.call(player, player.media, 'seeked');
6131 }
6132
6133 // Handle events
6134 // -1 Unstarted
6135 // 0 Ended
6136 // 1 Playing
6137 // 2 Paused
6138 // 3 Buffering
6139 // 5 Video cued
6140 switch (event.data) {
6141 case -1:
6142 // Update scrubber
6143 triggerEvent.call(player, player.media, 'timeupdate');
6144
6145 // Get loaded % from YouTube
6146 player.media.buffered = instance.getVideoLoadedFraction();
6147 triggerEvent.call(player, player.media, 'progress');
6148 break;
6149 case 0:
6150 assurePlaybackState.call(player, false);
6151
6152 // YouTube doesn't support loop for a single video, so mimick it.
6153 if (player.media.loop) {
6154 // YouTube needs a call to `stopVideo` before playing again
6155 instance.stopVideo();
6156 instance.playVideo();
6157 } else {
6158 triggerEvent.call(player, player.media, 'ended');
6159 }
6160 break;
6161 case 1:
6162 // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet)
6163 if (config.customControls && !player.config.autoplay && player.media.paused && !player.embed.hasPlayed) {
6164 player.media.pause();
6165 } else {
6166 assurePlaybackState.call(player, true);
6167 triggerEvent.call(player, player.media, 'playing');
6168
6169 // Poll to get playback progress
6170 player.timers.playing = setInterval(() => {
6171 triggerEvent.call(player, player.media, 'timeupdate');
6172 }, 50);
6173
6174 // Check duration again due to YouTube bug
6175 // https://github.com/sampotts/plyr/issues/374
6176 // https://code.google.com/p/gdata-issues/issues/detail?id=8690
6177 if (player.media.duration !== instance.getDuration()) {
6178 player.media.duration = instance.getDuration();
6179 triggerEvent.call(player, player.media, 'durationchange');
6180 }
6181 }
6182 break;
6183 case 2:
6184 // Restore audio (YouTube starts playing on seek if the video hasn't been played yet)
6185 if (!player.muted) {
6186 player.embed.unMute();
6187 }
6188 assurePlaybackState.call(player, false);
6189 break;
6190 case 3:
6191 // Trigger waiting event to add loading classes to container as the video buffers.
6192 triggerEvent.call(player, player.media, 'waiting');
6193 break;
6194 }
6195 triggerEvent.call(player, player.elements.container, 'statechange', false, {
6196 code: event.data
6197 });
6198 }
6199 }
6200 });
6201 }
6202 };
6203
6204 // ==========================================================================
6205 const media = {
6206 // Setup media
6207 setup() {
6208 // If there's no media, bail
6209 if (!this.media) {
6210 this.debug.warn('No media element found!');
6211 return;
6212 }
6213
6214 // Add type class
6215 toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);
6216
6217 // Add provider class
6218 toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);
6219
6220 // Add video class for embeds
6221 // This will require changes if audio embeds are added
6222 if (this.isEmbed) {
6223 toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);
6224 }
6225
6226 // Inject the player wrapper
6227 if (this.isVideo) {
6228 // Create the wrapper div
6229 this.elements.wrapper = createElement('div', {
6230 class: this.config.classNames.video
6231 });
6232
6233 // Wrap the video in a container
6234 wrap(this.media, this.elements.wrapper);
6235
6236 // Poster image container
6237 this.elements.poster = createElement('div', {
6238 class: this.config.classNames.poster
6239 });
6240 this.elements.wrapper.appendChild(this.elements.poster);
6241 }
6242 if (this.isHTML5) {
6243 html5.setup.call(this);
6244 } else if (this.isYouTube) {
6245 youtube.setup.call(this);
6246 } else if (this.isVimeo) {
6247 vimeo.setup.call(this);
6248 }
6249 }
6250 };
6251
6252 const destroy = instance => {
6253 // Destroy our adsManager
6254 if (instance.manager) {
6255 instance.manager.destroy();
6256 }
6257
6258 // Destroy our adsManager
6259 if (instance.elements.displayContainer) {
6260 instance.elements.displayContainer.destroy();
6261 }
6262 instance.elements.container.remove();
6263 };
6264 class Ads {
6265 /**
6266 * Ads constructor.
6267 * @param {Object} player
6268 * @return {Ads}
6269 */
6270 constructor(player) {
6271 /**
6272 * Load the IMA SDK
6273 */
6274 _defineProperty$1(this, "load", () => {
6275 if (!this.enabled) {
6276 return;
6277 }
6278
6279 // Check if the Google IMA3 SDK is loaded or load it ourselves
6280 if (!is.object(window.google) || !is.object(window.google.ima)) {
6281 loadScript(this.player.config.urls.googleIMA.sdk).then(() => {
6282 this.ready();
6283 }).catch(() => {
6284 // Script failed to load or is blocked
6285 this.trigger('error', new Error('Google IMA SDK failed to load'));
6286 });
6287 } else {
6288 this.ready();
6289 }
6290 });
6291 /**
6292 * Get the ads instance ready
6293 */
6294 _defineProperty$1(this, "ready", () => {
6295 // Double check we're enabled
6296 if (!this.enabled) {
6297 destroy(this);
6298 }
6299
6300 // Start ticking our safety timer. If the whole advertisement
6301 // thing doesn't resolve within our set time; we bail
6302 this.startSafetyTimer(12000, 'ready()');
6303
6304 // Clear the safety timer
6305 this.managerPromise.then(() => {
6306 this.clearSafetyTimer('onAdsManagerLoaded()');
6307 });
6308
6309 // Set listeners on the Plyr instance
6310 this.listeners();
6311
6312 // Setup the IMA SDK
6313 this.setupIMA();
6314 });
6315 /**
6316 * In order for the SDK to display ads for our video, we need to tell it where to put them,
6317 * so here we define our ad container. This div is set up to render on top of the video player.
6318 * Using the code below, we tell the SDK to render ads within that div. We also provide a
6319 * handle to the content video player - the SDK will poll the current time of our player to
6320 * properly place mid-rolls. After we create the ad display container, we initialize it. On
6321 * mobile devices, this initialization is done as the result of a user action.
6322 */
6323 _defineProperty$1(this, "setupIMA", () => {
6324 // Create the container for our advertisements
6325 this.elements.container = createElement('div', {
6326 class: this.player.config.classNames.ads
6327 });
6328 this.player.elements.container.appendChild(this.elements.container);
6329
6330 // So we can run VPAID2
6331 google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED);
6332
6333 // Set language
6334 google.ima.settings.setLocale(this.player.config.ads.language);
6335
6336 // Set playback for iOS10+
6337 google.ima.settings.setDisableCustomPlaybackForIOS10Plus(this.player.config.playsinline);
6338
6339 // We assume the adContainer is the video container of the plyr element that will house the ads
6340 this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container, this.player.media);
6341
6342 // Create ads loader
6343 this.loader = new google.ima.AdsLoader(this.elements.displayContainer);
6344
6345 // Listen and respond to ads loaded and error events
6346 this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false);
6347 this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false);
6348
6349 // Request video ads to be pre-loaded
6350 this.requestAds();
6351 });
6352 /**
6353 * Request advertisements
6354 */
6355 _defineProperty$1(this, "requestAds", () => {
6356 const {
6357 container
6358 } = this.player.elements;
6359 try {
6360 // Request video ads
6361 const request = new google.ima.AdsRequest();
6362 request.adTagUrl = this.tagUrl;
6363
6364 // Specify the linear and nonlinear slot sizes. This helps the SDK
6365 // to select the correct creative if multiple are returned
6366 request.linearAdSlotWidth = container.offsetWidth;
6367 request.linearAdSlotHeight = container.offsetHeight;
6368 request.nonLinearAdSlotWidth = container.offsetWidth;
6369 request.nonLinearAdSlotHeight = container.offsetHeight;
6370
6371 // We only overlay ads as we only support video.
6372 request.forceNonLinearFullSlot = false;
6373
6374 // Mute based on current state
6375 request.setAdWillPlayMuted(!this.player.muted);
6376 this.loader.requestAds(request);
6377 } catch (error) {
6378 this.onAdError(error);
6379 }
6380 });
6381 /**
6382 * Update the ad countdown
6383 * @param {Boolean} start
6384 */
6385 _defineProperty$1(this, "pollCountdown", (start = false) => {
6386 if (!start) {
6387 clearInterval(this.countdownTimer);
6388 this.elements.container.removeAttribute('data-badge-text');
6389 return;
6390 }
6391 const update = () => {
6392 const time = formatTime(Math.max(this.manager.getRemainingTime(), 0));
6393 const label = `${i18n.get('advertisement', this.player.config)} - ${time}`;
6394 this.elements.container.setAttribute('data-badge-text', label);
6395 };
6396 this.countdownTimer = setInterval(update, 100);
6397 });
6398 /**
6399 * This method is called whenever the ads are ready inside the AdDisplayContainer
6400 * @param {Event} event - adsManagerLoadedEvent
6401 */
6402 _defineProperty$1(this, "onAdsManagerLoaded", event => {
6403 // Load could occur after a source change (race condition)
6404 if (!this.enabled) {
6405 return;
6406 }
6407
6408 // Get the ads manager
6409 const settings = new google.ima.AdsRenderingSettings();
6410
6411 // Tell the SDK to save and restore content video state on our behalf
6412 settings.restoreCustomPlaybackStateOnAdBreakComplete = true;
6413 settings.enablePreloading = true;
6414
6415 // The SDK is polling currentTime on the contentPlayback. And needs a duration
6416 // so it can determine when to start the mid- and post-roll
6417 this.manager = event.getAdsManager(this.player, settings);
6418
6419 // Get the cue points for any mid-rolls by filtering out the pre- and post-roll
6420 this.cuePoints = this.manager.getCuePoints();
6421
6422 // Add listeners to the required events
6423 // Advertisement error events
6424 this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error));
6425
6426 // Advertisement regular events
6427 Object.keys(google.ima.AdEvent.Type).forEach(type => {
6428 this.manager.addEventListener(google.ima.AdEvent.Type[type], e => this.onAdEvent(e));
6429 });
6430
6431 // Resolve our adsManager
6432 this.trigger('loaded');
6433 });
6434 _defineProperty$1(this, "addCuePoints", () => {
6435 // Add advertisement cue's within the time line if available
6436 if (!is.empty(this.cuePoints)) {
6437 this.cuePoints.forEach(cuePoint => {
6438 if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {
6439 const seekElement = this.player.elements.progress;
6440 if (is.element(seekElement)) {
6441 const cuePercentage = 100 / this.player.duration * cuePoint;
6442 const cue = createElement('span', {
6443 class: this.player.config.classNames.cues
6444 });
6445 cue.style.left = `${cuePercentage.toString()}%`;
6446 seekElement.appendChild(cue);
6447 }
6448 }
6449 });
6450 }
6451 });
6452 /**
6453 * This is where all the event handling takes place. Retrieve the ad from the event. Some
6454 * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated
6455 * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type
6456 * @param {Event} event
6457 */
6458 _defineProperty$1(this, "onAdEvent", event => {
6459 const {
6460 container
6461 } = this.player.elements;
6462 // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)
6463 // don't have ad object associated
6464 const ad = event.getAd();
6465 const adData = event.getAdData();
6466
6467 // Proxy event
6468 const dispatchEvent = type => {
6469 triggerEvent.call(this.player, this.player.media, `ads${type.replace(/_/g, '').toLowerCase()}`);
6470 };
6471
6472 // Bubble the event
6473 dispatchEvent(event.type);
6474 switch (event.type) {
6475 case google.ima.AdEvent.Type.LOADED:
6476 // This is the first event sent for an ad - it is possible to determine whether the
6477 // ad is a video ad or an overlay
6478 this.trigger('loaded');
6479
6480 // Start countdown
6481 this.pollCountdown(true);
6482 if (!ad.isLinear()) {
6483 // Position AdDisplayContainer correctly for overlay
6484 ad.width = container.offsetWidth;
6485 ad.height = container.offsetHeight;
6486 }
6487
6488 // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());
6489 // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());
6490
6491 break;
6492 case google.ima.AdEvent.Type.STARTED:
6493 // Set volume to match player
6494 this.manager.setVolume(this.player.volume);
6495 break;
6496 case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:
6497 // All ads for the current videos are done. We can now request new advertisements
6498 // in case the video is re-played
6499
6500 // TODO: Example for what happens when a next video in a playlist would be loaded.
6501 // So here we load a new video when all ads are done.
6502 // Then we load new ads within a new adsManager. When the video
6503 // Is started - after - the ads are loaded, then we get ads.
6504 // You can also easily test cancelling and reloading by running
6505 // player.ads.cancel() and player.ads.play from the console I guess.
6506 // this.player.source = {
6507 // type: 'video',
6508 // title: 'View From A Blue Moon',
6509 // sources: [{
6510 // src:
6511 // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:
6512 // 'video/mp4', }], poster:
6513 // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:
6514 // [ { kind: 'captions', label: 'English', srclang: 'en', src:
6515 // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',
6516 // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:
6517 // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],
6518 // };
6519
6520 // TODO: So there is still this thing where a video should only be allowed to start
6521 // playing when the IMA SDK is ready or has failed
6522
6523 if (this.player.ended) {
6524 this.loadAds();
6525 } else {
6526 // The SDK won't allow new ads to be called without receiving a contentComplete()
6527 this.loader.contentComplete();
6528 }
6529 break;
6530 case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:
6531 // This event indicates the ad has started - the video player can adjust the UI,
6532 // for example display a pause button and remaining time. Fired when content should
6533 // be paused. This usually happens right before an ad is about to cover the content
6534
6535 this.pauseContent();
6536 break;
6537 case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:
6538 // This event indicates the ad has finished - the video player can perform
6539 // appropriate UI actions, such as removing the timer for remaining time detection.
6540 // Fired when content should be resumed. This usually happens when an ad finishes
6541 // or collapses
6542
6543 this.pollCountdown();
6544 this.resumeContent();
6545 break;
6546 case google.ima.AdEvent.Type.LOG:
6547 if (adData.adError) {
6548 this.player.debug.warn(`Non-fatal ad error: ${adData.adError.getMessage()}`);
6549 }
6550 break;
6551 }
6552 });
6553 /**
6554 * Any ad error handling comes through here
6555 * @param {Event} event
6556 */
6557 _defineProperty$1(this, "onAdError", event => {
6558 this.cancel();
6559 this.player.debug.warn('Ads error', event);
6560 });
6561 /**
6562 * Setup hooks for Plyr and window events. This ensures
6563 * the mid- and post-roll launch at the correct time. And
6564 * resize the advertisement when the player resizes
6565 */
6566 _defineProperty$1(this, "listeners", () => {
6567 const {
6568 container
6569 } = this.player.elements;
6570 let time;
6571 this.player.on('canplay', () => {
6572 this.addCuePoints();
6573 });
6574 this.player.on('ended', () => {
6575 this.loader.contentComplete();
6576 });
6577 this.player.on('timeupdate', () => {
6578 time = this.player.currentTime;
6579 });
6580 this.player.on('seeked', () => {
6581 const seekedTime = this.player.currentTime;
6582 if (is.empty(this.cuePoints)) {
6583 return;
6584 }
6585 this.cuePoints.forEach((cuePoint, index) => {
6586 if (time < cuePoint && cuePoint < seekedTime) {
6587 this.manager.discardAdBreak();
6588 this.cuePoints.splice(index, 1);
6589 }
6590 });
6591 });
6592
6593 // Listen to the resizing of the window. And resize ad accordingly
6594 // TODO: eventually implement ResizeObserver
6595 window.addEventListener('resize', () => {
6596 if (this.manager) {
6597 this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
6598 }
6599 });
6600 });
6601 /**
6602 * Initialize the adsManager and start playing advertisements
6603 */
6604 _defineProperty$1(this, "play", () => {
6605 const {
6606 container
6607 } = this.player.elements;
6608 if (!this.managerPromise) {
6609 this.resumeContent();
6610 }
6611
6612 // Play the requested advertisement whenever the adsManager is ready
6613 this.managerPromise.then(() => {
6614 // Set volume to match player
6615 this.manager.setVolume(this.player.volume);
6616
6617 // Initialize the container. Must be done via a user action on mobile devices
6618 this.elements.displayContainer.initialize();
6619 try {
6620 if (!this.initialized) {
6621 // Initialize the ads manager. Ad rules playlist will start at this time
6622 this.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
6623
6624 // Call play to start showing the ad. Single video and overlay ads will
6625 // start at this time; the call will be ignored for ad rules
6626 this.manager.start();
6627 }
6628 this.initialized = true;
6629 } catch (adError) {
6630 // An error may be thrown if there was a problem with the
6631 // VAST response
6632 this.onAdError(adError);
6633 }
6634 }).catch(() => {});
6635 });
6636 /**
6637 * Resume our video
6638 */
6639 _defineProperty$1(this, "resumeContent", () => {
6640 // Hide the advertisement container
6641 this.elements.container.style.zIndex = '';
6642
6643 // Ad is stopped
6644 this.playing = false;
6645
6646 // Play video
6647 silencePromise(this.player.media.play());
6648 });
6649 /**
6650 * Pause our video
6651 */
6652 _defineProperty$1(this, "pauseContent", () => {
6653 // Show the advertisement container
6654 this.elements.container.style.zIndex = 3;
6655
6656 // Ad is playing
6657 this.playing = true;
6658
6659 // Pause our video.
6660 this.player.media.pause();
6661 });
6662 /**
6663 * Destroy the adsManager so we can grab new ads after this. If we don't then we're not
6664 * allowed to call new ads based on google policies, as they interpret this as an accidental
6665 * video requests. https://developers.google.com/interactive-
6666 * media-ads/docs/sdks/android/faq#8
6667 */
6668 _defineProperty$1(this, "cancel", () => {
6669 // Pause our video
6670 if (this.initialized) {
6671 this.resumeContent();
6672 }
6673
6674 // Tell our instance that we're done for now
6675 this.trigger('error');
6676
6677 // Re-create our adsManager
6678 this.loadAds();
6679 });
6680 /**
6681 * Re-create our adsManager
6682 */
6683 _defineProperty$1(this, "loadAds", () => {
6684 // Tell our adsManager to go bye bye
6685 this.managerPromise.then(() => {
6686 // Destroy our adsManager
6687 if (this.manager) {
6688 this.manager.destroy();
6689 }
6690
6691 // Re-set our adsManager promises
6692 this.managerPromise = new Promise(resolve => {
6693 this.on('loaded', resolve);
6694 this.player.debug.log(this.manager);
6695 });
6696 // Now that the manager has been destroyed set it to also be un-initialized
6697 this.initialized = false;
6698
6699 // Now request some new advertisements
6700 this.requestAds();
6701 }).catch(() => {});
6702 });
6703 /**
6704 * Handles callbacks after an ad event was invoked
6705 * @param {String} event - Event type
6706 * @param args
6707 */
6708 _defineProperty$1(this, "trigger", (event, ...args) => {
6709 const handlers = this.events[event];
6710 if (is.array(handlers)) {
6711 handlers.forEach(handler => {
6712 if (is.function(handler)) {
6713 handler.apply(this, args);
6714 }
6715 });
6716 }
6717 });
6718 /**
6719 * Add event listeners
6720 * @param {String} event - Event type
6721 * @param {Function} callback - Callback for when event occurs
6722 * @return {Ads}
6723 */
6724 _defineProperty$1(this, "on", (event, callback) => {
6725 if (!is.array(this.events[event])) {
6726 this.events[event] = [];
6727 }
6728 this.events[event].push(callback);
6729 return this;
6730 });
6731 /**
6732 * Setup a safety timer for when the ad network doesn't respond for whatever reason.
6733 * The advertisement has 12 seconds to get its things together. We stop this timer when the
6734 * advertisement is playing, or when a user action is required to start, then we clear the
6735 * timer on ad ready
6736 * @param {Number} time
6737 * @param {String} from
6738 */
6739 _defineProperty$1(this, "startSafetyTimer", (time, from) => {
6740 this.player.debug.log(`Safety timer invoked from: ${from}`);
6741 this.safetyTimer = setTimeout(() => {
6742 this.cancel();
6743 this.clearSafetyTimer('startSafetyTimer()');
6744 }, time);
6745 });
6746 /**
6747 * Clear our safety timer(s)
6748 * @param {String} from
6749 */
6750 _defineProperty$1(this, "clearSafetyTimer", from => {
6751 if (!is.nullOrUndefined(this.safetyTimer)) {
6752 this.player.debug.log(`Safety timer cleared from: ${from}`);
6753 clearTimeout(this.safetyTimer);
6754 this.safetyTimer = null;
6755 }
6756 });
6757 this.player = player;
6758 this.config = player.config.ads;
6759 this.playing = false;
6760 this.initialized = false;
6761 this.elements = {
6762 container: null,
6763 displayContainer: null
6764 };
6765 this.manager = null;
6766 this.loader = null;
6767 this.cuePoints = null;
6768 this.events = {};
6769 this.safetyTimer = null;
6770 this.countdownTimer = null;
6771
6772 // Setup a promise to resolve when the IMA manager is ready
6773 this.managerPromise = new Promise((resolve, reject) => {
6774 // The ad is loaded and ready
6775 this.on('loaded', resolve);
6776
6777 // Ads failed
6778 this.on('error', reject);
6779 });
6780 this.load();
6781 }
6782 get enabled() {
6783 const {
6784 config
6785 } = this;
6786 return this.player.isHTML5 && this.player.isVideo && config.enabled && (!is.empty(config.publisherId) || is.url(config.tagUrl));
6787 }
6788 // Build the tag URL
6789 get tagUrl() {
6790 const {
6791 config
6792 } = this;
6793 if (is.url(config.tagUrl)) {
6794 return config.tagUrl;
6795 }
6796 const params = {
6797 AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',
6798 AV_CHANNELID: '5a0458dc28a06145e4519d21',
6799 AV_URL: window.location.hostname,
6800 cb: Date.now(),
6801 AV_WIDTH: 640,
6802 AV_HEIGHT: 480,
6803 AV_CDIM2: config.publisherId
6804 };
6805 const base = 'https://go.aniview.com/api/adserver6/vast/';
6806 return `${base}?${buildUrlParams(params)}`;
6807 }
6808 }
6809
6810 /**
6811 * Returns a number whose value is limited to the given range.
6812 *
6813 * Example: limit the output of this computation to between 0 and 255
6814 * (x * 255).clamp(0, 255)
6815 *
6816 * @param {Number} input
6817 * @param {Number} min The lower boundary of the output range
6818 * @param {Number} max The upper boundary of the output range
6819 * @returns A number within the bounds of min and max
6820 * @type Number
6821 */
6822 function clamp(input = 0, min = 0, max = 255) {
6823 return Math.min(Math.max(input, min), max);
6824 }
6825
6826 // Arg: vttDataString example: "WEBVTT\n\n1\n00:00:05.000 --> 00:00:10.000\n1080p-00001.jpg"
6827 const parseVtt = vttDataString => {
6828 const processedList = [];
6829 const frames = vttDataString.split(/\r\n\r\n|\n\n|\r\r/);
6830 frames.forEach(frame => {
6831 const result = {};
6832 const lines = frame.split(/\r\n|\n|\r/);
6833 lines.forEach(line => {
6834 if (!is.number(result.startTime)) {
6835 // The line with start and end times on it is the first line of interest
6836 const matchTimes = line.match(/([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})( ?--> ?)([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})/); // Note that this currently ignores caption formatting directives that are optionally on the end of this line - fine for non-captions VTT
6837
6838 if (matchTimes) {
6839 result.startTime = Number(matchTimes[1] || 0) * 60 * 60 + Number(matchTimes[2]) * 60 + Number(matchTimes[3]) + Number(`0.${matchTimes[4]}`);
6840 result.endTime = Number(matchTimes[6] || 0) * 60 * 60 + Number(matchTimes[7]) * 60 + Number(matchTimes[8]) + Number(`0.${matchTimes[9]}`);
6841 }
6842 } else if (!is.empty(line.trim()) && is.empty(result.text)) {
6843 // If we already have the startTime, then we're definitely up to the text line(s)
6844 const lineSplit = line.trim().split('#xywh=');
6845 [result.text] = lineSplit;
6846
6847 // If there's content in lineSplit[1], then we have sprites. If not, then it's just one frame per image
6848 if (lineSplit[1]) {
6849 [result.x, result.y, result.w, result.h] = lineSplit[1].split(',');
6850 }
6851 }
6852 });
6853 if (result.text) {
6854 processedList.push(result);
6855 }
6856 });
6857 return processedList;
6858 };
6859
6860 /**
6861 * Preview thumbnails for seek hover and scrubbing
6862 * Seeking: Hover over the seek bar (desktop only): shows a small preview container above the seek bar
6863 * Scrubbing: Click and drag the seek bar (desktop and mobile): shows the preview image over the entire video, as if the video is scrubbing at very high speed
6864 *
6865 * Notes:
6866 * - Thumbs are set via JS settings on Plyr init, not HTML5 'track' property. Using the track property would be a bit gross, because it doesn't support custom 'kinds'. kind=metadata might be used for something else, and we want to allow multiple thumbnails tracks. Tracks must have a unique combination of 'kind' and 'label'. We would have to do something like kind=metadata,label=thumbnails1 / kind=metadata,label=thumbnails2. Square peg, round hole
6867 * - VTT info: the image URL is relative to the VTT, not the current document. But if the url starts with a slash, it will naturally be relative to the current domain. https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
6868 * - This implementation uses multiple separate img elements. Other implementations use background-image on one element. This would be nice and simple, but Firefox and Safari have flickering issues with replacing backgrounds of larger images. It seems that YouTube perhaps only avoids this because they don't have the option for high-res previews (even the fullscreen ones, when mousedown/seeking). Images appear over the top of each other, and previous ones are discarded once the new ones have been rendered
6869 */
6870
6871 const fitRatio = (ratio, outer) => {
6872 const targetRatio = outer.width / outer.height;
6873 const result = {};
6874 if (ratio > targetRatio) {
6875 result.width = outer.width;
6876 result.height = 1 / ratio * outer.width;
6877 } else {
6878 result.height = outer.height;
6879 result.width = ratio * outer.height;
6880 }
6881 return result;
6882 };
6883 class PreviewThumbnails {
6884 /**
6885 * PreviewThumbnails constructor.
6886 * @param {Plyr} player
6887 * @return {PreviewThumbnails}
6888 */
6889 constructor(player) {
6890 _defineProperty$1(this, "load", () => {
6891 // Toggle the regular seek tooltip
6892 if (this.player.elements.display.seekTooltip) {
6893 this.player.elements.display.seekTooltip.hidden = this.enabled;
6894 }
6895 if (!this.enabled) return;
6896 this.getThumbnails().then(() => {
6897 if (!this.enabled) {
6898 return;
6899 }
6900
6901 // Render DOM elements
6902 this.render();
6903
6904 // Check to see if thumb container size was specified manually in CSS
6905 this.determineContainerAutoSizing();
6906
6907 // Set up listeners
6908 this.listeners();
6909 this.loaded = true;
6910 });
6911 });
6912 // Download VTT files and parse them
6913 _defineProperty$1(this, "getThumbnails", () => {
6914 return new Promise(resolve => {
6915 const {
6916 src
6917 } = this.player.config.previewThumbnails;
6918 if (is.empty(src)) {
6919 throw new Error('Missing previewThumbnails.src config attribute');
6920 }
6921
6922 // Resolve promise
6923 const sortAndResolve = () => {
6924 // Sort smallest to biggest (e.g., [120p, 480p, 1080p])
6925 this.thumbnails.sort((x, y) => x.height - y.height);
6926 this.player.debug.log('Preview thumbnails', this.thumbnails);
6927 resolve();
6928 };
6929
6930 // Via callback()
6931 if (is.function(src)) {
6932 src(thumbnails => {
6933 this.thumbnails = thumbnails;
6934 sortAndResolve();
6935 });
6936 }
6937 // VTT urls
6938 else {
6939 // If string, convert into single-element list
6940 const urls = is.string(src) ? [src] : src;
6941 // Loop through each src URL. Download and process the VTT file, storing the resulting data in this.thumbnails
6942 const promises = urls.map(u => this.getThumbnail(u));
6943 // Resolve
6944 Promise.all(promises).then(sortAndResolve);
6945 }
6946 });
6947 });
6948 // Process individual VTT file
6949 _defineProperty$1(this, "getThumbnail", url => {
6950 return new Promise(resolve => {
6951 fetch(url).then(response => {
6952 const thumbnail = {
6953 frames: parseVtt(response),
6954 height: null,
6955 urlPrefix: ''
6956 };
6957
6958 // If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file
6959 // If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank
6960 // If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file
6961 if (!thumbnail.frames[0].text.startsWith('/') && !thumbnail.frames[0].text.startsWith('http://') && !thumbnail.frames[0].text.startsWith('https://')) {
6962 thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1);
6963 }
6964
6965 // Download the first frame, so that we can determine/set the height of this thumbnailsDef
6966 const tempImage = new Image();
6967 tempImage.onload = () => {
6968 thumbnail.height = tempImage.naturalHeight;
6969 thumbnail.width = tempImage.naturalWidth;
6970 this.thumbnails.push(thumbnail);
6971 resolve();
6972 };
6973 tempImage.src = thumbnail.urlPrefix + thumbnail.frames[0].text;
6974 });
6975 });
6976 });
6977 _defineProperty$1(this, "startMove", event => {
6978 if (!this.loaded) return;
6979 if (!is.event(event) || !['touchmove', 'mousemove'].includes(event.type)) return;
6980
6981 // Wait until media has a duration
6982 if (!this.player.media.duration) return;
6983 if (event.type === 'touchmove') {
6984 // Calculate seek hover position as approx video seconds
6985 this.seekTime = this.player.media.duration * (this.player.elements.inputs.seek.value / 100);
6986 } else {
6987 var _this$player$config$m, _this$player$config$m2;
6988 // Calculate seek hover position as approx video seconds
6989 const clientRect = this.player.elements.progress.getBoundingClientRect();
6990 const percentage = 100 / clientRect.width * (event.pageX - clientRect.left);
6991 this.seekTime = this.player.media.duration * (percentage / 100);
6992 if (this.seekTime < 0) {
6993 // The mousemove fires for 10+px out to the left
6994 this.seekTime = 0;
6995 }
6996 if (this.seekTime > this.player.media.duration - 1) {
6997 // Took 1 second off the duration for safety, because different players can disagree on the real duration of a video
6998 this.seekTime = this.player.media.duration - 1;
6999 }
7000 this.mousePosX = event.pageX;
7001
7002 // Set time text inside image container
7003 this.elements.thumb.time.innerText = formatTime(this.seekTime);
7004
7005 // Get marker point for time
7006 const point = (_this$player$config$m = this.player.config.markers) === null || _this$player$config$m === void 0 ? void 0 : (_this$player$config$m2 = _this$player$config$m.points) === null || _this$player$config$m2 === void 0 ? void 0 : _this$player$config$m2.find(({
7007 time: t
7008 }) => t === Math.round(this.seekTime));
7009
7010 // Append the point label to the tooltip
7011 if (point) {
7012 // this.elements.thumb.time.innerText.concat('\n');
7013 this.elements.thumb.time.insertAdjacentHTML('afterbegin', `${point.label}<br>`);
7014 }
7015 }
7016
7017 // Download and show image
7018 this.showImageAtCurrentTime();
7019 });
7020 _defineProperty$1(this, "endMove", () => {
7021 this.toggleThumbContainer(false, true);
7022 });
7023 _defineProperty$1(this, "startScrubbing", event => {
7024 // Only act on left mouse button (0), or touch device (event.button does not exist or is false)
7025 if (is.nullOrUndefined(event.button) || event.button === false || event.button === 0) {
7026 this.mouseDown = true;
7027
7028 // Wait until media has a duration
7029 if (this.player.media.duration) {
7030 this.toggleScrubbingContainer(true);
7031 this.toggleThumbContainer(false, true);
7032
7033 // Download and show image
7034 this.showImageAtCurrentTime();
7035 }
7036 }
7037 });
7038 _defineProperty$1(this, "endScrubbing", () => {
7039 this.mouseDown = false;
7040
7041 // Hide scrubbing preview. But wait until the video has successfully seeked before hiding the scrubbing preview
7042 if (Math.ceil(this.lastTime) === Math.ceil(this.player.media.currentTime)) {
7043 // The video was already seeked/loaded at the chosen time - hide immediately
7044 this.toggleScrubbingContainer(false);
7045 } else {
7046 // The video hasn't seeked yet. Wait for that
7047 once.call(this.player, this.player.media, 'timeupdate', () => {
7048 // Re-check mousedown - we might have already started scrubbing again
7049 if (!this.mouseDown) {
7050 this.toggleScrubbingContainer(false);
7051 }
7052 });
7053 }
7054 });
7055 /**
7056 * Setup hooks for Plyr and window events
7057 */
7058 _defineProperty$1(this, "listeners", () => {
7059 // Hide thumbnail preview - on mouse click, mouse leave (in listeners.js for now), and video play/seek. All four are required, e.g., for buffering
7060 this.player.on('play', () => {
7061 this.toggleThumbContainer(false, true);
7062 });
7063 this.player.on('seeked', () => {
7064 this.toggleThumbContainer(false);
7065 });
7066 this.player.on('timeupdate', () => {
7067 this.lastTime = this.player.media.currentTime;
7068 });
7069 });
7070 /**
7071 * Create HTML elements for image containers
7072 */
7073 _defineProperty$1(this, "render", () => {
7074 // Create HTML element: plyr__preview-thumbnail-container
7075 this.elements.thumb.container = createElement('div', {
7076 class: this.player.config.classNames.previewThumbnails.thumbContainer
7077 });
7078
7079 // Wrapper for the image for styling
7080 this.elements.thumb.imageContainer = createElement('div', {
7081 class: this.player.config.classNames.previewThumbnails.imageContainer
7082 });
7083 this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer);
7084
7085 // Create HTML element, parent+span: time text (e.g., 01:32:00)
7086 const timeContainer = createElement('div', {
7087 class: this.player.config.classNames.previewThumbnails.timeContainer
7088 });
7089 this.elements.thumb.time = createElement('span', {}, '00:00');
7090 timeContainer.appendChild(this.elements.thumb.time);
7091 this.elements.thumb.imageContainer.appendChild(timeContainer);
7092
7093 // Inject the whole thumb
7094 if (is.element(this.player.elements.progress)) {
7095 this.player.elements.progress.appendChild(this.elements.thumb.container);
7096 }
7097
7098 // Create HTML element: plyr__preview-scrubbing-container
7099 this.elements.scrubbing.container = createElement('div', {
7100 class: this.player.config.classNames.previewThumbnails.scrubbingContainer
7101 });
7102 this.player.elements.wrapper.appendChild(this.elements.scrubbing.container);
7103 });
7104 _defineProperty$1(this, "destroy", () => {
7105 if (this.elements.thumb.container) {
7106 this.elements.thumb.container.remove();
7107 }
7108 if (this.elements.scrubbing.container) {
7109 this.elements.scrubbing.container.remove();
7110 }
7111 });
7112 _defineProperty$1(this, "showImageAtCurrentTime", () => {
7113 if (this.mouseDown) {
7114 this.setScrubbingContainerSize();
7115 } else {
7116 this.setThumbContainerSizeAndPos();
7117 }
7118
7119 // Find the desired thumbnail index
7120 // TODO: Handle a video longer than the thumbs where thumbNum is null
7121 const thumbNum = this.thumbnails[0].frames.findIndex(frame => this.seekTime >= frame.startTime && this.seekTime <= frame.endTime);
7122 const hasThumb = thumbNum >= 0;
7123 let qualityIndex = 0;
7124
7125 // Show the thumb container if we're not scrubbing
7126 if (!this.mouseDown) {
7127 this.toggleThumbContainer(hasThumb);
7128 }
7129
7130 // No matching thumb found
7131 if (!hasThumb) {
7132 return;
7133 }
7134
7135 // Check to see if we've already downloaded higher quality versions of this image
7136 this.thumbnails.forEach((thumbnail, index) => {
7137 if (this.loadedImages.includes(thumbnail.frames[thumbNum].text)) {
7138 qualityIndex = index;
7139 }
7140 });
7141
7142 // Only proceed if either thumb num or thumbfilename has changed
7143 if (thumbNum !== this.showingThumb) {
7144 this.showingThumb = thumbNum;
7145 this.loadImage(qualityIndex);
7146 }
7147 });
7148 // Show the image that's currently specified in this.showingThumb
7149 _defineProperty$1(this, "loadImage", (qualityIndex = 0) => {
7150 const thumbNum = this.showingThumb;
7151 const thumbnail = this.thumbnails[qualityIndex];
7152 const {
7153 urlPrefix
7154 } = thumbnail;
7155 const frame = thumbnail.frames[thumbNum];
7156 const thumbFilename = thumbnail.frames[thumbNum].text;
7157 const thumbUrl = urlPrefix + thumbFilename;
7158 if (!this.currentImageElement || this.currentImageElement.dataset.filename !== thumbFilename) {
7159 // If we're already loading a previous image, remove its onload handler - we don't want it to load after this one
7160 // Only do this if not using sprites. Without sprites we really want to show as many images as possible, as a best-effort
7161 if (this.loadingImage && this.usingSprites) {
7162 this.loadingImage.onload = null;
7163 }
7164
7165 // We're building and adding a new image. In other implementations of similar functionality (YouTube), background image
7166 // is instead used. But this causes issues with larger images in Firefox and Safari - switching between background
7167 // images causes a flicker. Putting a new image over the top does not
7168 const previewImage = new Image();
7169 previewImage.src = thumbUrl;
7170 previewImage.dataset.index = thumbNum;
7171 previewImage.dataset.filename = thumbFilename;
7172 this.showingThumbFilename = thumbFilename;
7173 this.player.debug.log(`Loading image: ${thumbUrl}`);
7174
7175 // For some reason, passing the named function directly causes it to execute immediately. So I've wrapped it in an anonymous function...
7176 previewImage.onload = () => this.showImage(previewImage, frame, qualityIndex, thumbNum, thumbFilename, true);
7177 this.loadingImage = previewImage;
7178 this.removeOldImages(previewImage);
7179 } else {
7180 // Update the existing image
7181 this.showImage(this.currentImageElement, frame, qualityIndex, thumbNum, thumbFilename, false);
7182 this.currentImageElement.dataset.index = thumbNum;
7183 this.removeOldImages(this.currentImageElement);
7184 }
7185 });
7186 _defineProperty$1(this, "showImage", (previewImage, frame, qualityIndex, thumbNum, thumbFilename, newImage = true) => {
7187 this.player.debug.log(`Showing thumb: ${thumbFilename}. num: ${thumbNum}. qual: ${qualityIndex}. newimg: ${newImage}`);
7188 this.setImageSizeAndOffset(previewImage, frame);
7189 if (newImage) {
7190 this.currentImageContainer.appendChild(previewImage);
7191 this.currentImageElement = previewImage;
7192 if (!this.loadedImages.includes(thumbFilename)) {
7193 this.loadedImages.push(thumbFilename);
7194 }
7195 }
7196
7197 // Preload images before and after the current one
7198 // Show higher quality of the same frame
7199 // Each step here has a short time delay, and only continues if still hovering/seeking the same spot. This is to protect slow connections from overloading
7200 this.preloadNearby(thumbNum, true).then(this.preloadNearby(thumbNum, false)).then(this.getHigherQuality(qualityIndex, previewImage, frame, thumbFilename));
7201 });
7202 // Remove all preview images that aren't the designated current image
7203 _defineProperty$1(this, "removeOldImages", currentImage => {
7204 // Get a list of all images, convert it from a DOM list to an array
7205 Array.from(this.currentImageContainer.children).forEach(image => {
7206 if (image.tagName.toLowerCase() !== 'img') {
7207 return;
7208 }
7209 const removeDelay = this.usingSprites ? 500 : 1000;
7210 if (image.dataset.index !== currentImage.dataset.index && !image.dataset.deleting) {
7211 // Wait 200ms, as the new image can take some time to show on certain browsers (even though it was downloaded before showing). This will prevent flicker, and show some generosity towards slower clients
7212 // First set attribute 'deleting' to prevent multi-handling of this on repeat firing of this function
7213 // eslint-disable-next-line no-param-reassign
7214 image.dataset.deleting = true;
7215
7216 // This has to be set before the timeout - to prevent issues switching between hover and scrub
7217 const {
7218 currentImageContainer
7219 } = this;
7220 setTimeout(() => {
7221 currentImageContainer.removeChild(image);
7222 this.player.debug.log(`Removing thumb: ${image.dataset.filename}`);
7223 }, removeDelay);
7224 }
7225 });
7226 });
7227 // Preload images before and after the current one. Only if the user is still hovering/seeking the same frame
7228 // This will only preload the lowest quality
7229 _defineProperty$1(this, "preloadNearby", (thumbNum, forward = true) => {
7230 return new Promise(resolve => {
7231 setTimeout(() => {
7232 const oldThumbFilename = this.thumbnails[0].frames[thumbNum].text;
7233 if (this.showingThumbFilename === oldThumbFilename) {
7234 // Find the nearest thumbs with different filenames. Sometimes it'll be the next index, but in the case of sprites, it might be 100+ away
7235 let thumbnailsClone;
7236 if (forward) {
7237 thumbnailsClone = this.thumbnails[0].frames.slice(thumbNum);
7238 } else {
7239 thumbnailsClone = this.thumbnails[0].frames.slice(0, thumbNum).reverse();
7240 }
7241 let foundOne = false;
7242 thumbnailsClone.forEach(frame => {
7243 const newThumbFilename = frame.text;
7244 if (newThumbFilename !== oldThumbFilename) {
7245 // Found one with a different filename. Make sure it hasn't already been loaded on this page visit
7246 if (!this.loadedImages.includes(newThumbFilename)) {
7247 foundOne = true;
7248 this.player.debug.log(`Preloading thumb filename: ${newThumbFilename}`);
7249 const {
7250 urlPrefix
7251 } = this.thumbnails[0];
7252 const thumbURL = urlPrefix + newThumbFilename;
7253 const previewImage = new Image();
7254 previewImage.src = thumbURL;
7255 previewImage.onload = () => {
7256 this.player.debug.log(`Preloaded thumb filename: ${newThumbFilename}`);
7257 if (!this.loadedImages.includes(newThumbFilename)) this.loadedImages.push(newThumbFilename);
7258
7259 // We don't resolve until the thumb is loaded
7260 resolve();
7261 };
7262 }
7263 }
7264 });
7265
7266 // If there are none to preload then we want to resolve immediately
7267 if (!foundOne) {
7268 resolve();
7269 }
7270 }
7271 }, 300);
7272 });
7273 });
7274 // If user has been hovering current image for half a second, look for a higher quality one
7275 _defineProperty$1(this, "getHigherQuality", (currentQualityIndex, previewImage, frame, thumbFilename) => {
7276 if (currentQualityIndex < this.thumbnails.length - 1) {
7277 // Only use the higher quality version if it's going to look any better - if the current thumb is of a lower pixel density than the thumbnail container
7278 let previewImageHeight = previewImage.naturalHeight;
7279 if (this.usingSprites) {
7280 previewImageHeight = frame.h;
7281 }
7282 if (previewImageHeight < this.thumbContainerHeight) {
7283 // Recurse back to the loadImage function - show a higher quality one, but only if the viewer is on this frame for a while
7284 setTimeout(() => {
7285 // Make sure the mouse hasn't already moved on and started hovering at another image
7286 if (this.showingThumbFilename === thumbFilename) {
7287 this.player.debug.log(`Showing higher quality thumb for: ${thumbFilename}`);
7288 this.loadImage(currentQualityIndex + 1);
7289 }
7290 }, 300);
7291 }
7292 }
7293 });
7294 _defineProperty$1(this, "toggleThumbContainer", (toggle = false, clearShowing = false) => {
7295 const className = this.player.config.classNames.previewThumbnails.thumbContainerShown;
7296 this.elements.thumb.container.classList.toggle(className, toggle);
7297 if (!toggle && clearShowing) {
7298 this.showingThumb = null;
7299 this.showingThumbFilename = null;
7300 }
7301 });
7302 _defineProperty$1(this, "toggleScrubbingContainer", (toggle = false) => {
7303 const className = this.player.config.classNames.previewThumbnails.scrubbingContainerShown;
7304 this.elements.scrubbing.container.classList.toggle(className, toggle);
7305 if (!toggle) {
7306 this.showingThumb = null;
7307 this.showingThumbFilename = null;
7308 }
7309 });
7310 _defineProperty$1(this, "determineContainerAutoSizing", () => {
7311 if (this.elements.thumb.imageContainer.clientHeight > 20 || this.elements.thumb.imageContainer.clientWidth > 20) {
7312 // This will prevent auto sizing in this.setThumbContainerSizeAndPos()
7313 this.sizeSpecifiedInCSS = true;
7314 }
7315 });
7316 // Set the size to be about a quarter of the size of video. Unless option dynamicSize === false, in which case it needs to be set in CSS
7317 _defineProperty$1(this, "setThumbContainerSizeAndPos", () => {
7318 const {
7319 imageContainer
7320 } = this.elements.thumb;
7321 if (!this.sizeSpecifiedInCSS) {
7322 const thumbWidth = Math.floor(this.thumbContainerHeight * this.thumbAspectRatio);
7323 imageContainer.style.height = `${this.thumbContainerHeight}px`;
7324 imageContainer.style.width = `${thumbWidth}px`;
7325 } else if (imageContainer.clientHeight > 20 && imageContainer.clientWidth < 20) {
7326 const thumbWidth = Math.floor(imageContainer.clientHeight * this.thumbAspectRatio);
7327 imageContainer.style.width = `${thumbWidth}px`;
7328 } else if (imageContainer.clientHeight < 20 && imageContainer.clientWidth > 20) {
7329 const thumbHeight = Math.floor(imageContainer.clientWidth / this.thumbAspectRatio);
7330 imageContainer.style.height = `${thumbHeight}px`;
7331 }
7332 this.setThumbContainerPos();
7333 });
7334 _defineProperty$1(this, "setThumbContainerPos", () => {
7335 const scrubberRect = this.player.elements.progress.getBoundingClientRect();
7336 const containerRect = this.player.elements.container.getBoundingClientRect();
7337 const {
7338 container
7339 } = this.elements.thumb;
7340 // Find the lowest and highest desired left-position, so we don't slide out the side of the video container
7341 const min = containerRect.left - scrubberRect.left + 10;
7342 const max = containerRect.right - scrubberRect.left - container.clientWidth - 10;
7343 // Set preview container position to: mousepos, minus seekbar.left, minus half of previewContainer.clientWidth
7344 const position = this.mousePosX - scrubberRect.left - container.clientWidth / 2;
7345 const clamped = clamp(position, min, max);
7346
7347 // Move the popover position
7348 container.style.left = `${clamped}px`;
7349
7350 // The arrow can follow the cursor
7351 container.style.setProperty('--preview-arrow-offset', `${position - clamped}px`);
7352 });
7353 // Can't use 100% width, in case the video is a different aspect ratio to the video container
7354 _defineProperty$1(this, "setScrubbingContainerSize", () => {
7355 const {
7356 width,
7357 height
7358 } = fitRatio(this.thumbAspectRatio, {
7359 width: this.player.media.clientWidth,
7360 height: this.player.media.clientHeight
7361 });
7362 this.elements.scrubbing.container.style.width = `${width}px`;
7363 this.elements.scrubbing.container.style.height = `${height}px`;
7364 });
7365 // Sprites need to be offset to the correct location
7366 _defineProperty$1(this, "setImageSizeAndOffset", (previewImage, frame) => {
7367 if (!this.usingSprites) return;
7368
7369 // Find difference between height and preview container height
7370 const multiplier = this.thumbContainerHeight / frame.h;
7371
7372 // eslint-disable-next-line no-param-reassign
7373 previewImage.style.height = `${previewImage.naturalHeight * multiplier}px`;
7374 // eslint-disable-next-line no-param-reassign
7375 previewImage.style.width = `${previewImage.naturalWidth * multiplier}px`;
7376 // eslint-disable-next-line no-param-reassign
7377 previewImage.style.left = `-${frame.x * multiplier}px`;
7378 // eslint-disable-next-line no-param-reassign
7379 previewImage.style.top = `-${frame.y * multiplier}px`;
7380 });
7381 this.player = player;
7382 this.thumbnails = [];
7383 this.loaded = false;
7384 this.lastMouseMoveTime = Date.now();
7385 this.mouseDown = false;
7386 this.loadedImages = [];
7387 this.elements = {
7388 thumb: {},
7389 scrubbing: {}
7390 };
7391 this.load();
7392 }
7393 get enabled() {
7394 return this.player.isHTML5 && this.player.isVideo && this.player.config.previewThumbnails.enabled;
7395 }
7396 get currentImageContainer() {
7397 return this.mouseDown ? this.elements.scrubbing.container : this.elements.thumb.imageContainer;
7398 }
7399 get usingSprites() {
7400 return Object.keys(this.thumbnails[0].frames[0]).includes('w');
7401 }
7402 get thumbAspectRatio() {
7403 if (this.usingSprites) {
7404 return this.thumbnails[0].frames[0].w / this.thumbnails[0].frames[0].h;
7405 }
7406 return this.thumbnails[0].width / this.thumbnails[0].height;
7407 }
7408 get thumbContainerHeight() {
7409 if (this.mouseDown) {
7410 const {
7411 height
7412 } = fitRatio(this.thumbAspectRatio, {
7413 width: this.player.media.clientWidth,
7414 height: this.player.media.clientHeight
7415 });
7416 return height;
7417 }
7418
7419 // If css is used this needs to return the css height for sprites to work (see setImageSizeAndOffset)
7420 if (this.sizeSpecifiedInCSS) {
7421 return this.elements.thumb.imageContainer.clientHeight;
7422 }
7423 return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio / 4);
7424 }
7425 get currentImageElement() {
7426 return this.mouseDown ? this.currentScrubbingImageElement : this.currentThumbnailImageElement;
7427 }
7428 set currentImageElement(element) {
7429 if (this.mouseDown) {
7430 this.currentScrubbingImageElement = element;
7431 } else {
7432 this.currentThumbnailImageElement = element;
7433 }
7434 }
7435 }
7436
7437 // ==========================================================================
7438 const source = {
7439 // Add elements to HTML5 media (source, tracks, etc)
7440 insertElements(type, attributes) {
7441 if (is.string(attributes)) {
7442 insertElement(type, this.media, {
7443 src: attributes
7444 });
7445 } else if (is.array(attributes)) {
7446 attributes.forEach(attribute => {
7447 insertElement(type, this.media, attribute);
7448 });
7449 }
7450 },
7451 // Update source
7452 // Sources are not checked for support so be careful
7453 change(input) {
7454 if (!getDeep(input, 'sources.length')) {
7455 this.debug.warn('Invalid source format');
7456 return;
7457 }
7458
7459 // Cancel current network requests
7460 html5.cancelRequests.call(this);
7461
7462 // Destroy instance and re-setup
7463 this.destroy.call(this, () => {
7464 // Reset quality options
7465 this.options.quality = [];
7466
7467 // Remove elements
7468 removeElement(this.media);
7469 this.media = null;
7470
7471 // Reset class name
7472 if (is.element(this.elements.container)) {
7473 this.elements.container.removeAttribute('class');
7474 }
7475
7476 // Set the type and provider
7477 const {
7478 sources,
7479 type
7480 } = input;
7481 const [{
7482 provider = providers.html5,
7483 src
7484 }] = sources;
7485 const tagName = provider === 'html5' ? type : 'div';
7486 const attributes = provider === 'html5' ? {} : {
7487 src
7488 };
7489 Object.assign(this, {
7490 provider,
7491 type,
7492 // Check for support
7493 supported: support.check(type, provider, this.config.playsinline),
7494 // Create new element
7495 media: createElement(tagName, attributes)
7496 });
7497
7498 // Inject the new element
7499 this.elements.container.appendChild(this.media);
7500
7501 // Autoplay the new source?
7502 if (is.boolean(input.autoplay)) {
7503 this.config.autoplay = input.autoplay;
7504 }
7505
7506 // Set attributes for audio and video
7507 if (this.isHTML5) {
7508 if (this.config.crossorigin) {
7509 this.media.setAttribute('crossorigin', '');
7510 }
7511 if (this.config.autoplay) {
7512 this.media.setAttribute('autoplay', '');
7513 }
7514 if (!is.empty(input.poster)) {
7515 this.poster = input.poster;
7516 }
7517 if (this.config.loop.active) {
7518 this.media.setAttribute('loop', '');
7519 }
7520 if (this.config.muted) {
7521 this.media.setAttribute('muted', '');
7522 }
7523 if (this.config.playsinline) {
7524 this.media.setAttribute('playsinline', '');
7525 }
7526 }
7527
7528 // Restore class hook
7529 ui.addStyleHook.call(this);
7530
7531 // Set new sources for html5
7532 if (this.isHTML5) {
7533 source.insertElements.call(this, 'source', sources);
7534 }
7535
7536 // Set video title
7537 this.config.title = input.title;
7538
7539 // Set up from scratch
7540 media.setup.call(this);
7541
7542 // HTML5 stuff
7543 if (this.isHTML5) {
7544 // Setup captions
7545 if (Object.keys(input).includes('tracks')) {
7546 source.insertElements.call(this, 'track', input.tracks);
7547 }
7548 }
7549
7550 // If HTML5 or embed but not fully supported, setupInterface and call ready now
7551 if (this.isHTML5 || this.isEmbed && !this.supported.ui) {
7552 // Setup interface
7553 ui.build.call(this);
7554 }
7555
7556 // Load HTML5 sources
7557 if (this.isHTML5) {
7558 this.media.load();
7559 }
7560
7561 // Update previewThumbnails config & reload plugin
7562 if (!is.empty(input.previewThumbnails)) {
7563 Object.assign(this.config.previewThumbnails, input.previewThumbnails);
7564
7565 // Cleanup previewThumbnails plugin if it was loaded
7566 if (this.previewThumbnails && this.previewThumbnails.loaded) {
7567 this.previewThumbnails.destroy();
7568 this.previewThumbnails = null;
7569 }
7570
7571 // Create new instance if it is still enabled
7572 if (this.config.previewThumbnails.enabled) {
7573 this.previewThumbnails = new PreviewThumbnails(this);
7574 }
7575 }
7576
7577 // Update the fullscreen support
7578 this.fullscreen.update();
7579 }, true);
7580 }
7581 };
7582
7583 // Private properties
7584 // TODO: Use a WeakMap for private globals
7585 // const globals = new WeakMap();
7586
7587 // Plyr instance
7588 class Plyr {
7589 constructor(target, options) {
7590 /**
7591 * Play the media, or play the advertisement (if they are not blocked)
7592 */
7593 _defineProperty$1(this, "play", () => {
7594 if (!is.function(this.media.play)) {
7595 return null;
7596 }
7597
7598 // Intecept play with ads
7599 if (this.ads && this.ads.enabled) {
7600 this.ads.managerPromise.then(() => this.ads.play()).catch(() => silencePromise(this.media.play()));
7601 }
7602
7603 // Return the promise (for HTML5)
7604 return this.media.play();
7605 });
7606 /**
7607 * Pause the media
7608 */
7609 _defineProperty$1(this, "pause", () => {
7610 if (!this.playing || !is.function(this.media.pause)) {
7611 return null;
7612 }
7613 return this.media.pause();
7614 });
7615 /**
7616 * Toggle playback based on current status
7617 * @param {Boolean} input
7618 */
7619 _defineProperty$1(this, "togglePlay", input => {
7620 // Toggle based on current state if nothing passed
7621 const toggle = is.boolean(input) ? input : !this.playing;
7622 if (toggle) {
7623 return this.play();
7624 }
7625 return this.pause();
7626 });
7627 /**
7628 * Stop playback
7629 */
7630 _defineProperty$1(this, "stop", () => {
7631 if (this.isHTML5) {
7632 this.pause();
7633 this.restart();
7634 } else if (is.function(this.media.stop)) {
7635 this.media.stop();
7636 }
7637 });
7638 /**
7639 * Restart playback
7640 */
7641 _defineProperty$1(this, "restart", () => {
7642 this.currentTime = 0;
7643 });
7644 /**
7645 * Rewind
7646 * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
7647 */
7648 _defineProperty$1(this, "rewind", seekTime => {
7649 this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime;
7650 });
7651 /**
7652 * Fast forward
7653 * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
7654 */
7655 _defineProperty$1(this, "forward", seekTime => {
7656 this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime;
7657 });
7658 /**
7659 * Increase volume
7660 * @param {Boolean} step - How much to decrease by (between 0 and 1)
7661 */
7662 _defineProperty$1(this, "increaseVolume", step => {
7663 const volume = this.media.muted ? 0 : this.volume;
7664 this.volume = volume + (is.number(step) ? step : 0);
7665 });
7666 /**
7667 * Decrease volume
7668 * @param {Boolean} step - How much to decrease by (between 0 and 1)
7669 */
7670 _defineProperty$1(this, "decreaseVolume", step => {
7671 this.increaseVolume(-step);
7672 });
7673 /**
7674 * Trigger the airplay dialog
7675 * TODO: update player with state, support, enabled
7676 */
7677 _defineProperty$1(this, "airplay", () => {
7678 // Show dialog if supported
7679 if (support.airplay) {
7680 this.media.webkitShowPlaybackTargetPicker();
7681 }
7682 });
7683 /**
7684 * Toggle the player controls
7685 * @param {Boolean} [toggle] - Whether to show the controls
7686 */
7687 _defineProperty$1(this, "toggleControls", toggle => {
7688 // Don't toggle if missing UI support or if it's audio
7689 if (this.supported.ui && !this.isAudio) {
7690 // Get state before change
7691 const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);
7692 // Negate the argument if not undefined since adding the class to hides the controls
7693 const force = typeof toggle === 'undefined' ? undefined : !toggle;
7694 // Apply and get updated state
7695 const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);
7696
7697 // Close menu
7698 if (hiding && is.array(this.config.controls) && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
7699 controls.toggleMenu.call(this, false);
7700 }
7701
7702 // Trigger event on change
7703 if (hiding !== isHidden) {
7704 const eventName = hiding ? 'controlshidden' : 'controlsshown';
7705 triggerEvent.call(this, this.media, eventName);
7706 }
7707 return !hiding;
7708 }
7709 return false;
7710 });
7711 /**
7712 * Add event listeners
7713 * @param {String} event - Event type
7714 * @param {Function} callback - Callback for when event occurs
7715 */
7716 _defineProperty$1(this, "on", (event, callback) => {
7717 on.call(this, this.elements.container, event, callback);
7718 });
7719 /**
7720 * Add event listeners once
7721 * @param {String} event - Event type
7722 * @param {Function} callback - Callback for when event occurs
7723 */
7724 _defineProperty$1(this, "once", (event, callback) => {
7725 once.call(this, this.elements.container, event, callback);
7726 });
7727 /**
7728 * Remove event listeners
7729 * @param {String} event - Event type
7730 * @param {Function} callback - Callback for when event occurs
7731 */
7732 _defineProperty$1(this, "off", (event, callback) => {
7733 off(this.elements.container, event, callback);
7734 });
7735 /**
7736 * Destroy an instance
7737 * Event listeners are removed when elements are removed
7738 * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
7739 * @param {Function} callback - Callback for when destroy is complete
7740 * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)
7741 */
7742 _defineProperty$1(this, "destroy", (callback, soft = false) => {
7743 if (!this.ready) {
7744 return;
7745 }
7746 const done = () => {
7747 // Reset overflow (incase destroyed while in fullscreen)
7748 document.body.style.overflow = '';
7749
7750 // GC for embed
7751 this.embed = null;
7752
7753 // If it's a soft destroy, make minimal changes
7754 if (soft) {
7755 if (Object.keys(this.elements).length) {
7756 // Remove elements
7757 removeElement(this.elements.buttons.play);
7758 removeElement(this.elements.captions);
7759 removeElement(this.elements.controls);
7760 removeElement(this.elements.wrapper);
7761
7762 // Clear for GC
7763 this.elements.buttons.play = null;
7764 this.elements.captions = null;
7765 this.elements.controls = null;
7766 this.elements.wrapper = null;
7767 }
7768
7769 // Callback
7770 if (is.function(callback)) {
7771 callback();
7772 }
7773 } else {
7774 // Unbind listeners
7775 unbindListeners.call(this);
7776
7777 // Cancel current network requests
7778 html5.cancelRequests.call(this);
7779
7780 // Replace the container with the original element provided
7781 replaceElement(this.elements.original, this.elements.container);
7782
7783 // Event
7784 triggerEvent.call(this, this.elements.original, 'destroyed', true);
7785
7786 // Callback
7787 if (is.function(callback)) {
7788 callback.call(this.elements.original);
7789 }
7790
7791 // Reset state
7792 this.ready = false;
7793
7794 // Clear for garbage collection
7795 setTimeout(() => {
7796 this.elements = null;
7797 this.media = null;
7798 }, 200);
7799 }
7800 };
7801
7802 // Stop playback
7803 this.stop();
7804
7805 // Clear timeouts
7806 clearTimeout(this.timers.loading);
7807 clearTimeout(this.timers.controls);
7808 clearTimeout(this.timers.resized);
7809
7810 // Provider specific stuff
7811 if (this.isHTML5) {
7812 // Restore native video controls
7813 ui.toggleNativeControls.call(this, true);
7814
7815 // Clean up
7816 done();
7817 } else if (this.isYouTube) {
7818 // Clear timers
7819 clearInterval(this.timers.buffering);
7820 clearInterval(this.timers.playing);
7821
7822 // Destroy YouTube API
7823 if (this.embed !== null && is.function(this.embed.destroy)) {
7824 this.embed.destroy();
7825 }
7826
7827 // Clean up
7828 done();
7829 } else if (this.isVimeo) {
7830 // Destroy Vimeo API
7831 // then clean up (wait, to prevent postmessage errors)
7832 if (this.embed !== null) {
7833 this.embed.unload().then(done);
7834 }
7835
7836 // Vimeo does not always return
7837 setTimeout(done, 200);
7838 }
7839 });
7840 /**
7841 * Check for support for a mime type (HTML5 only)
7842 * @param {String} type - Mime type
7843 */
7844 _defineProperty$1(this, "supports", type => support.mime.call(this, type));
7845 this.timers = {};
7846
7847 // State
7848 this.ready = false;
7849 this.loading = false;
7850 this.failed = false;
7851
7852 // Touch device
7853 this.touch = support.touch;
7854
7855 // Set the media element
7856 this.media = target;
7857
7858 // String selector passed
7859 if (is.string(this.media)) {
7860 this.media = document.querySelectorAll(this.media);
7861 }
7862
7863 // jQuery, NodeList or Array passed, use first element
7864 if (window.jQuery && this.media instanceof jQuery || is.nodeList(this.media) || is.array(this.media)) {
7865 // eslint-disable-next-line
7866 this.media = this.media[0];
7867 }
7868
7869 // Set config
7870 this.config = extend({}, defaults, Plyr.defaults, options || {}, (() => {
7871 try {
7872 return JSON.parse(this.media.getAttribute('data-plyr-config'));
7873 } catch (_) {
7874 return {};
7875 }
7876 })());
7877
7878 // Elements cache
7879 this.elements = {
7880 container: null,
7881 fullscreen: null,
7882 captions: null,
7883 buttons: {},
7884 display: {},
7885 progress: {},
7886 inputs: {},
7887 settings: {
7888 popup: null,
7889 menu: null,
7890 panels: {},
7891 buttons: {}
7892 }
7893 };
7894
7895 // Captions
7896 this.captions = {
7897 active: null,
7898 currentTrack: -1,
7899 meta: new WeakMap()
7900 };
7901
7902 // Fullscreen
7903 this.fullscreen = {
7904 active: false
7905 };
7906
7907 // Options
7908 this.options = {
7909 speed: [],
7910 quality: []
7911 };
7912
7913 // Debugging
7914 // TODO: move to globals
7915 this.debug = new Console(this.config.debug);
7916
7917 // Log config options and support
7918 this.debug.log('Config', this.config);
7919 this.debug.log('Support', support);
7920
7921 // We need an element to setup
7922 if (is.nullOrUndefined(this.media) || !is.element(this.media)) {
7923 this.debug.error('Setup failed: no suitable element passed');
7924 return;
7925 }
7926
7927 // Bail if the element is initialized
7928 if (this.media.plyr) {
7929 this.debug.warn('Target already setup');
7930 return;
7931 }
7932
7933 // Bail if not enabled
7934 if (!this.config.enabled) {
7935 this.debug.error('Setup failed: disabled by config');
7936 return;
7937 }
7938
7939 // Bail if disabled or no basic support
7940 // You may want to disable certain UAs etc
7941 if (!support.check().api) {
7942 this.debug.error('Setup failed: no support');
7943 return;
7944 }
7945
7946 // Cache original element state for .destroy()
7947 const clone = this.media.cloneNode(true);
7948 clone.autoplay = false;
7949 this.elements.original = clone;
7950
7951 // Set media type based on tag or data attribute
7952 // Supported: video, audio, vimeo, youtube
7953 const _type = this.media.tagName.toLowerCase();
7954 // Embed properties
7955 let iframe = null;
7956 let url = null;
7957
7958 // Different setup based on type
7959 switch (_type) {
7960 case 'div':
7961 // Find the frame
7962 iframe = this.media.querySelector('iframe');
7963
7964 // <iframe> type
7965 if (is.element(iframe)) {
7966 // Detect provider
7967 url = parseUrl(iframe.getAttribute('src'));
7968 this.provider = getProviderByUrl(url.toString());
7969
7970 // Rework elements
7971 this.elements.container = this.media;
7972 this.media = iframe;
7973
7974 // Reset classname
7975 this.elements.container.className = '';
7976
7977 // Get attributes from URL and set config
7978 if (url.search.length) {
7979 const truthy = ['1', 'true'];
7980 if (truthy.includes(url.searchParams.get('autoplay'))) {
7981 this.config.autoplay = true;
7982 }
7983 if (truthy.includes(url.searchParams.get('loop'))) {
7984 this.config.loop.active = true;
7985 }
7986
7987 // TODO: replace fullscreen.iosNative with this playsinline config option
7988 // YouTube requires the playsinline in the URL
7989 if (this.isYouTube) {
7990 this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
7991 this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?
7992 } else {
7993 this.config.playsinline = true;
7994 }
7995 }
7996 } else {
7997 // <div> with attributes
7998 this.provider = this.media.getAttribute(this.config.attributes.embed.provider);
7999
8000 // Remove attribute
8001 this.media.removeAttribute(this.config.attributes.embed.provider);
8002 }
8003
8004 // Unsupported or missing provider
8005 if (is.empty(this.provider) || !Object.values(providers).includes(this.provider)) {
8006 this.debug.error('Setup failed: Invalid provider');
8007 return;
8008 }
8009
8010 // Audio will come later for external providers
8011 this.type = types.video;
8012 break;
8013 case 'video':
8014 case 'audio':
8015 this.type = _type;
8016 this.provider = providers.html5;
8017
8018 // Get config from attributes
8019 if (this.media.hasAttribute('crossorigin')) {
8020 this.config.crossorigin = true;
8021 }
8022 if (this.media.hasAttribute('autoplay')) {
8023 this.config.autoplay = true;
8024 }
8025 if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {
8026 this.config.playsinline = true;
8027 }
8028 if (this.media.hasAttribute('muted')) {
8029 this.config.muted = true;
8030 }
8031 if (this.media.hasAttribute('loop')) {
8032 this.config.loop.active = true;
8033 }
8034 break;
8035 default:
8036 this.debug.error('Setup failed: unsupported type');
8037 return;
8038 }
8039
8040 // Check for support again but with type
8041 this.supported = support.check(this.type, this.provider);
8042
8043 // If no support for even API, bail
8044 if (!this.supported.api) {
8045 this.debug.error('Setup failed: no support');
8046 return;
8047 }
8048 this.eventListeners = [];
8049
8050 // Create listeners
8051 this.listeners = new Listeners(this);
8052
8053 // Setup local storage for user settings
8054 this.storage = new Storage(this);
8055
8056 // Store reference
8057 this.media.plyr = this;
8058
8059 // Wrap media
8060 if (!is.element(this.elements.container)) {
8061 this.elements.container = createElement('div');
8062 wrap(this.media, this.elements.container);
8063 }
8064
8065 // Migrate custom properties from media to container (so they work 😉)
8066 ui.migrateStyles.call(this);
8067
8068 // Add style hook
8069 ui.addStyleHook.call(this);
8070
8071 // Setup media
8072 media.setup.call(this);
8073
8074 // Listen for events if debugging
8075 if (this.config.debug) {
8076 on.call(this, this.elements.container, this.config.events.join(' '), event => {
8077 this.debug.log(`event: ${event.type}`);
8078 });
8079 }
8080
8081 // Setup fullscreen
8082 this.fullscreen = new Fullscreen(this);
8083
8084 // Setup interface
8085 // If embed but not fully supported, build interface now to avoid flash of controls
8086 if (this.isHTML5 || this.isEmbed && !this.supported.ui) {
8087 ui.build.call(this);
8088 }
8089
8090 // Container listeners
8091 this.listeners.container();
8092
8093 // Global listeners
8094 this.listeners.global();
8095
8096 // Setup ads if provided
8097 if (this.config.ads.enabled) {
8098 this.ads = new Ads(this);
8099 }
8100
8101 // Autoplay if required
8102 if (this.isHTML5 && this.config.autoplay) {
8103 this.once('canplay', () => silencePromise(this.play()));
8104 }
8105
8106 // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek
8107 this.lastSeekTime = 0;
8108
8109 // Setup preview thumbnails if enabled
8110 if (this.config.previewThumbnails.enabled) {
8111 this.previewThumbnails = new PreviewThumbnails(this);
8112 }
8113 }
8114
8115 // ---------------------------------------
8116 // API
8117 // ---------------------------------------
8118
8119 /**
8120 * Types and provider helpers
8121 */
8122 get isHTML5() {
8123 return this.provider === providers.html5;
8124 }
8125 get isEmbed() {
8126 return this.isYouTube || this.isVimeo;
8127 }
8128 get isYouTube() {
8129 return this.provider === providers.youtube;
8130 }
8131 get isVimeo() {
8132 return this.provider === providers.vimeo;
8133 }
8134 get isVideo() {
8135 return this.type === types.video;
8136 }
8137 get isAudio() {
8138 return this.type === types.audio;
8139 }
8140 /**
8141 * Get playing state
8142 */
8143 get playing() {
8144 return Boolean(this.ready && !this.paused && !this.ended);
8145 }
8146
8147 /**
8148 * Get paused state
8149 */
8150 get paused() {
8151 return Boolean(this.media.paused);
8152 }
8153
8154 /**
8155 * Get stopped state
8156 */
8157 get stopped() {
8158 return Boolean(this.paused && this.currentTime === 0);
8159 }
8160
8161 /**
8162 * Get ended state
8163 */
8164 get ended() {
8165 return Boolean(this.media.ended);
8166 }
8167 /**
8168 * Seek to a time
8169 * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)
8170 */
8171 set currentTime(input) {
8172 // Bail if media duration isn't available yet
8173 if (!this.duration) {
8174 return;
8175 }
8176
8177 // Validate input
8178 const inputIsValid = is.number(input) && input > 0;
8179
8180 // Set
8181 this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;
8182
8183 // Logging
8184 this.debug.log(`Seeking to ${this.currentTime} seconds`);
8185 }
8186
8187 /**
8188 * Get current time
8189 */
8190 get currentTime() {
8191 return Number(this.media.currentTime);
8192 }
8193
8194 /**
8195 * Get buffered
8196 */
8197 get buffered() {
8198 const {
8199 buffered
8200 } = this.media;
8201
8202 // YouTube / Vimeo return a float between 0-1
8203 if (is.number(buffered)) {
8204 return buffered;
8205 }
8206
8207 // HTML5
8208 // TODO: Handle buffered chunks of the media
8209 // (i.e. seek to another section buffers only that section)
8210 if (buffered && buffered.length && this.duration > 0) {
8211 return buffered.end(0) / this.duration;
8212 }
8213 return 0;
8214 }
8215
8216 /**
8217 * Get seeking status
8218 */
8219 get seeking() {
8220 return Boolean(this.media.seeking);
8221 }
8222
8223 /**
8224 * Get the duration of the current media
8225 */
8226 get duration() {
8227 // Faux duration set via config
8228 const fauxDuration = parseFloat(this.config.duration);
8229 // Media duration can be NaN or Infinity before the media has loaded
8230 const realDuration = (this.media || {}).duration;
8231 const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;
8232
8233 // If config duration is funky, use regular duration
8234 return fauxDuration || duration;
8235 }
8236
8237 /**
8238 * Set the player volume
8239 * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage
8240 */
8241 set volume(value) {
8242 let volume = value;
8243 const max = 1;
8244 const min = 0;
8245 if (is.string(volume)) {
8246 volume = Number(volume);
8247 }
8248
8249 // Load volume from storage if no value specified
8250 if (!is.number(volume)) {
8251 volume = this.storage.get('volume');
8252 }
8253
8254 // Use config if all else fails
8255 if (!is.number(volume)) {
8256 ({
8257 volume
8258 } = this.config);
8259 }
8260
8261 // Maximum is volumeMax
8262 if (volume > max) {
8263 volume = max;
8264 }
8265 // Minimum is volumeMin
8266 if (volume < min) {
8267 volume = min;
8268 }
8269
8270 // Update config
8271 this.config.volume = volume;
8272
8273 // Set the player volume
8274 this.media.volume = volume;
8275
8276 // If muted, and we're increasing volume manually, reset muted state
8277 if (!is.empty(value) && this.muted && volume > 0) {
8278 this.muted = false;
8279 }
8280 }
8281
8282 /**
8283 * Get the current player volume
8284 */
8285 get volume() {
8286 return Number(this.media.volume);
8287 }
8288 /**
8289 * Set muted state
8290 * @param {Boolean} mute
8291 */
8292 set muted(mute) {
8293 let toggle = mute;
8294
8295 // Load muted state from storage
8296 if (!is.boolean(toggle)) {
8297 toggle = this.storage.get('muted');
8298 }
8299
8300 // Use config if all else fails
8301 if (!is.boolean(toggle)) {
8302 toggle = this.config.muted;
8303 }
8304
8305 // Update config
8306 this.config.muted = toggle;
8307
8308 // Set mute on the player
8309 this.media.muted = toggle;
8310 }
8311
8312 /**
8313 * Get current muted state
8314 */
8315 get muted() {
8316 return Boolean(this.media.muted);
8317 }
8318
8319 /**
8320 * Check if the media has audio
8321 */
8322 get hasAudio() {
8323 // Assume yes for all non HTML5 (as we can't tell...)
8324 if (!this.isHTML5) {
8325 return true;
8326 }
8327 if (this.isAudio) {
8328 return true;
8329 }
8330
8331 // Get audio tracks
8332 return Boolean(this.media.mozHasAudio) || Boolean(this.media.webkitAudioDecodedByteCount) || Boolean(this.media.audioTracks && this.media.audioTracks.length);
8333 }
8334
8335 /**
8336 * Set playback speed
8337 * @param {Number} input - the speed of playback (0.5-2.0)
8338 */
8339 set speed(input) {
8340 let speed = null;
8341 if (is.number(input)) {
8342 speed = input;
8343 }
8344 if (!is.number(speed)) {
8345 speed = this.storage.get('speed');
8346 }
8347 if (!is.number(speed)) {
8348 speed = this.config.speed.selected;
8349 }
8350
8351 // Clamp to min/max
8352 const {
8353 minimumSpeed: min,
8354 maximumSpeed: max
8355 } = this;
8356 speed = clamp(speed, min, max);
8357
8358 // Update config
8359 this.config.speed.selected = speed;
8360
8361 // Set media speed
8362 setTimeout(() => {
8363 if (this.media) {
8364 this.media.playbackRate = speed;
8365 }
8366 }, 0);
8367 }
8368
8369 /**
8370 * Get current playback speed
8371 */
8372 get speed() {
8373 return Number(this.media.playbackRate);
8374 }
8375
8376 /**
8377 * Get the minimum allowed speed
8378 */
8379 get minimumSpeed() {
8380 if (this.isYouTube) {
8381 // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
8382 return Math.min(...this.options.speed);
8383 }
8384 if (this.isVimeo) {
8385 // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
8386 return 0.5;
8387 }
8388
8389 // https://stackoverflow.com/a/32320020/1191319
8390 return 0.0625;
8391 }
8392
8393 /**
8394 * Get the maximum allowed speed
8395 */
8396 get maximumSpeed() {
8397 if (this.isYouTube) {
8398 // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
8399 return Math.max(...this.options.speed);
8400 }
8401 if (this.isVimeo) {
8402 // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
8403 return 2;
8404 }
8405
8406 // https://stackoverflow.com/a/32320020/1191319
8407 return 16;
8408 }
8409
8410 /**
8411 * Set playback quality
8412 * Currently HTML5 & YouTube only
8413 * @param {Number} input - Quality level
8414 */
8415 set quality(input) {
8416 const config = this.config.quality;
8417 const options = this.options.quality;
8418 if (!options.length) {
8419 return;
8420 }
8421 let quality = [!is.empty(input) && Number(input), this.storage.get('quality'), config.selected, config.default].find(is.number);
8422 let updateStorage = true;
8423 if (!options.includes(quality)) {
8424 const value = closest(options, quality);
8425 this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
8426 quality = value;
8427
8428 // Don't update storage if quality is not supported
8429 updateStorage = false;
8430 }
8431
8432 // Update config
8433 config.selected = quality;
8434
8435 // Set quality
8436 this.media.quality = quality;
8437
8438 // Save to storage
8439 if (updateStorage) {
8440 this.storage.set({
8441 quality
8442 });
8443 }
8444 }
8445
8446 /**
8447 * Get current quality level
8448 */
8449 get quality() {
8450 return this.media.quality;
8451 }
8452
8453 /**
8454 * Toggle loop
8455 * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config
8456 * @param {Boolean} input - Whether to loop or not
8457 */
8458 set loop(input) {
8459 const toggle = is.boolean(input) ? input : this.config.loop.active;
8460 this.config.loop.active = toggle;
8461 this.media.loop = toggle;
8462
8463 // Set default to be a true toggle
8464 /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';
8465 switch (type) {
8466 case 'start':
8467 if (this.config.loop.end && this.config.loop.end <= this.currentTime) {
8468 this.config.loop.end = null;
8469 }
8470 this.config.loop.start = this.currentTime;
8471 // this.config.loop.indicator.start = this.elements.display.played.value;
8472 break;
8473 case 'end':
8474 if (this.config.loop.start >= this.currentTime) {
8475 return this;
8476 }
8477 this.config.loop.end = this.currentTime;
8478 // this.config.loop.indicator.end = this.elements.display.played.value;
8479 break;
8480 case 'all':
8481 this.config.loop.start = 0;
8482 this.config.loop.end = this.duration - 2;
8483 this.config.loop.indicator.start = 0;
8484 this.config.loop.indicator.end = 100;
8485 break;
8486 case 'toggle':
8487 if (this.config.loop.active) {
8488 this.config.loop.start = 0;
8489 this.config.loop.end = null;
8490 } else {
8491 this.config.loop.start = 0;
8492 this.config.loop.end = this.duration - 2;
8493 }
8494 break;
8495 default:
8496 this.config.loop.start = 0;
8497 this.config.loop.end = null;
8498 break;
8499 } */
8500 }
8501
8502 /**
8503 * Get current loop state
8504 */
8505 get loop() {
8506 return Boolean(this.media.loop);
8507 }
8508
8509 /**
8510 * Set new media source
8511 * @param {Object} input - The new source object (see docs)
8512 */
8513 set source(input) {
8514 source.change.call(this, input);
8515 }
8516
8517 /**
8518 * Get current source
8519 */
8520 get source() {
8521 return this.media.currentSrc;
8522 }
8523
8524 /**
8525 * Get a download URL (either source or custom)
8526 */
8527 get download() {
8528 const {
8529 download
8530 } = this.config.urls;
8531 return is.url(download) ? download : this.source;
8532 }
8533
8534 /**
8535 * Set the download URL
8536 */
8537 set download(input) {
8538 if (!is.url(input)) {
8539 return;
8540 }
8541 this.config.urls.download = input;
8542 controls.setDownloadUrl.call(this);
8543 }
8544
8545 /**
8546 * Set the poster image for a video
8547 * @param {String} input - the URL for the new poster image
8548 */
8549 set poster(input) {
8550 if (!this.isVideo) {
8551 this.debug.warn('Poster can only be set for video');
8552 return;
8553 }
8554 ui.setPoster.call(this, input, false).catch(() => {});
8555 }
8556
8557 /**
8558 * Get the current poster image
8559 */
8560 get poster() {
8561 if (!this.isVideo) {
8562 return null;
8563 }
8564 return this.media.getAttribute('poster') || this.media.getAttribute('data-poster');
8565 }
8566
8567 /**
8568 * Get the current aspect ratio in use
8569 */
8570 get ratio() {
8571 if (!this.isVideo) {
8572 return null;
8573 }
8574 const ratio = reduceAspectRatio(getAspectRatio.call(this));
8575 return is.array(ratio) ? ratio.join(':') : ratio;
8576 }
8577
8578 /**
8579 * Set video aspect ratio
8580 */
8581 set ratio(input) {
8582 if (!this.isVideo) {
8583 this.debug.warn('Aspect ratio can only be set for video');
8584 return;
8585 }
8586 if (!is.string(input) || !validateAspectRatio(input)) {
8587 this.debug.error(`Invalid aspect ratio specified (${input})`);
8588 return;
8589 }
8590 this.config.ratio = reduceAspectRatio(input);
8591 setAspectRatio.call(this);
8592 }
8593
8594 /**
8595 * Set the autoplay state
8596 * @param {Boolean} input - Whether to autoplay or not
8597 */
8598 set autoplay(input) {
8599 this.config.autoplay = is.boolean(input) ? input : this.config.autoplay;
8600 }
8601
8602 /**
8603 * Get the current autoplay state
8604 */
8605 get autoplay() {
8606 return Boolean(this.config.autoplay);
8607 }
8608
8609 /**
8610 * Toggle captions
8611 * @param {Boolean} input - Whether to enable captions
8612 */
8613 toggleCaptions(input) {
8614 captions.toggle.call(this, input, false);
8615 }
8616
8617 /**
8618 * Set the caption track by index
8619 * @param {Number} input - Caption index
8620 */
8621 set currentTrack(input) {
8622 captions.set.call(this, input, false);
8623 captions.setup.call(this);
8624 }
8625
8626 /**
8627 * Get the current caption track index (-1 if disabled)
8628 */
8629 get currentTrack() {
8630 const {
8631 toggled,
8632 currentTrack
8633 } = this.captions;
8634 return toggled ? currentTrack : -1;
8635 }
8636
8637 /**
8638 * Set the wanted language for captions
8639 * Since tracks can be added later it won't update the actual caption track until there is a matching track
8640 * @param {String} input - Two character ISO language code (e.g. EN, FR, PT, etc)
8641 */
8642 set language(input) {
8643 captions.setLanguage.call(this, input, false);
8644 }
8645
8646 /**
8647 * Get the current track's language
8648 */
8649 get language() {
8650 return (captions.getCurrentTrack.call(this) || {}).language;
8651 }
8652
8653 /**
8654 * Toggle picture-in-picture playback on WebKit/MacOS
8655 * TODO: update player with state, support, enabled
8656 * TODO: detect outside changes
8657 */
8658 set pip(input) {
8659 // Bail if no support
8660 if (!support.pip) {
8661 return;
8662 }
8663
8664 // Toggle based on current state if not passed
8665 const toggle = is.boolean(input) ? input : !this.pip;
8666
8667 // Toggle based on current state
8668 // Safari
8669 if (is.function(this.media.webkitSetPresentationMode)) {
8670 this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);
8671 }
8672
8673 // Chrome
8674 if (is.function(this.media.requestPictureInPicture)) {
8675 if (!this.pip && toggle) {
8676 this.media.requestPictureInPicture();
8677 } else if (this.pip && !toggle) {
8678 document.exitPictureInPicture();
8679 }
8680 }
8681 }
8682
8683 /**
8684 * Get the current picture-in-picture state
8685 */
8686 get pip() {
8687 if (!support.pip) {
8688 return null;
8689 }
8690
8691 // Safari
8692 if (!is.empty(this.media.webkitPresentationMode)) {
8693 return this.media.webkitPresentationMode === pip.active;
8694 }
8695
8696 // Chrome
8697 return this.media === document.pictureInPictureElement;
8698 }
8699
8700 /**
8701 * Sets the preview thumbnails for the current source
8702 */
8703 setPreviewThumbnails(thumbnailSource) {
8704 if (this.previewThumbnails && this.previewThumbnails.loaded) {
8705 this.previewThumbnails.destroy();
8706 this.previewThumbnails = null;
8707 }
8708 Object.assign(this.config.previewThumbnails, thumbnailSource);
8709
8710 // Create new instance if it is still enabled
8711 if (this.config.previewThumbnails.enabled) {
8712 this.previewThumbnails = new PreviewThumbnails(this);
8713 }
8714 }
8715 /**
8716 * Check for support
8717 * @param {String} type - Player type (audio/video)
8718 * @param {String} provider - Provider (html5/youtube/vimeo)
8719 */
8720 static supported(type, provider) {
8721 return support.check(type, provider);
8722 }
8723
8724 /**
8725 * Load an SVG sprite into the page
8726 * @param {String} url - URL for the SVG sprite
8727 * @param {String} [id] - Unique ID
8728 */
8729 static loadSprite(url, id) {
8730 return loadSprite(url, id);
8731 }
8732
8733 /**
8734 * Setup multiple instances
8735 * @param {*} selector
8736 * @param {Object} options
8737 */
8738 static setup(selector, options = {}) {
8739 let targets = null;
8740 if (is.string(selector)) {
8741 targets = Array.from(document.querySelectorAll(selector));
8742 } else if (is.nodeList(selector)) {
8743 targets = Array.from(selector);
8744 } else if (is.array(selector)) {
8745 targets = selector.filter(is.element);
8746 }
8747 if (is.empty(targets)) {
8748 return null;
8749 }
8750 return targets.map(t => new Plyr(t, options));
8751 }
8752 }
8753 Plyr.defaults = cloneDeep(defaults);
8754
8755 return Plyr;
8756
8757}));
8758//# sourceMappingURL=plyr.js.map