UNPKG

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