PageSourceSearch

https://www.tehrantimes.com/resources/theme/global/js/plyr/plyr.js

js tehrantimes.com collected 2026-09-24 07:48:12 UTC 297,534 bytes, 8,764 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.