UNPKG

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