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