UNPKG

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