typeof navigator === "object" && (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define('Plyr', factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Plyr = factory()); })(this, (function () { 'use strict'; function _defineProperty$1(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _classCallCheck(e, t) { if (!(e instanceof t)) throw new TypeError("Cannot call a class as a function"); } function _defineProperties(e, t) { for (var n = 0; n < t.length; n++) { var r = t[n]; r.enumerable = r.enumerable || !1, r.configurable = !0, "value" in r && (r.writable = !0), Object.defineProperty(e, r.key, r); } } function _createClass(e, t, n) { return t && _defineProperties(e.prototype, t), n && _defineProperties(e, n), e; } function _defineProperty(e, t, n) { return t in e ? Object.defineProperty(e, t, { value: n, enumerable: !0, configurable: !0, writable: !0 }) : e[t] = n, e; } function ownKeys(e, t) { var n = Object.keys(e); if (Object.getOwnPropertySymbols) { var r = Object.getOwnPropertySymbols(e); t && (r = r.filter(function (t) { return Object.getOwnPropertyDescriptor(e, t).enumerable; })), n.push.apply(n, r); } return n; } function _objectSpread2(e) { for (var t = 1; t < arguments.length; t++) { var n = null != arguments[t] ? arguments[t] : {}; t % 2 ? ownKeys(Object(n), !0).forEach(function (t) { _defineProperty(e, t, n[t]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : ownKeys(Object(n)).forEach(function (t) { Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t)); }); } return e; } var defaults$1 = { addCSS: !0, thumbWidth: 15, watch: !0 }; function matches$1(e, t) { return function () { return Array.from(document.querySelectorAll(t)).includes(this); }.call(e, t); } function trigger(e, t) { if (e && t) { var n = new Event(t, { bubbles: !0 }); e.dispatchEvent(n); } } var getConstructor$1 = function (e) { return null != e ? e.constructor : null; }, instanceOf$1 = function (e, t) { return !!(e && t && e instanceof t); }, isNullOrUndefined$1 = function (e) { return null == e; }, isObject$1 = function (e) { return getConstructor$1(e) === Object; }, isNumber$1 = function (e) { return getConstructor$1(e) === Number && !Number.isNaN(e); }, isString$1 = function (e) { return getConstructor$1(e) === String; }, isBoolean$1 = function (e) { return getConstructor$1(e) === Boolean; }, isFunction$1 = function (e) { return getConstructor$1(e) === Function; }, isArray$1 = function (e) { return Array.isArray(e); }, isNodeList$1 = function (e) { return instanceOf$1(e, NodeList); }, isElement$1 = function (e) { return instanceOf$1(e, Element); }, isEvent$1 = function (e) { return instanceOf$1(e, Event); }, isEmpty$1 = function (e) { return isNullOrUndefined$1(e) || (isString$1(e) || isArray$1(e) || isNodeList$1(e)) && !e.length || isObject$1(e) && !Object.keys(e).length; }, is$1 = { nullOrUndefined: isNullOrUndefined$1, object: isObject$1, number: isNumber$1, string: isString$1, boolean: isBoolean$1, function: isFunction$1, array: isArray$1, nodeList: isNodeList$1, element: isElement$1, event: isEvent$1, empty: isEmpty$1 }; function getDecimalPlaces(e) { var t = "".concat(e).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/); return t ? Math.max(0, (t[1] ? t[1].length : 0) - (t[2] ? +t[2] : 0)) : 0; } function round(e, t) { if (1 > t) { var n = getDecimalPlaces(t); return parseFloat(e.toFixed(n)); } return Math.round(e / t) * t; } var RangeTouch = function () { function e(t, n) { _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()); } return _createClass(e, [{ key: "init", value: function () { 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); } }, { key: "destroy", value: function () { e.enabled && (this.config.addCSS && (this.element.style.userSelect = "", this.element.style.webKitUserSelect = "", this.element.style.touchAction = ""), this.listeners(!1), this.element.rangeTouch = null); } }, { key: "listeners", value: function (e) { var t = this, n = e ? "addEventListener" : "removeEventListener"; ["touchstart", "touchmove", "touchend"].forEach(function (e) { t.element[n](e, function (e) { return t.set(e); }, !1); }); } }, { key: "get", value: function (t) { if (!e.enabled || !is$1.event(t)) return null; var n, r = t.target, i = t.changedTouches[0], o = parseFloat(r.getAttribute("min")) || 0, s = parseFloat(r.getAttribute("max")) || 100, u = parseFloat(r.getAttribute("step")) || 1, c = r.getBoundingClientRect(), a = 100 / c.width * (this.config.thumbWidth / 2) / 100; 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); } }, { key: "set", value: function (t) { 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")); } }], [{ key: "setup", value: function (t) { var n = 1 < arguments.length && void 0 !== arguments[1] ? arguments[1] : {}, r = null; 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; var i = _objectSpread2({}, defaults$1, {}, n); if (is$1.string(t) && i.watch) { var o = new MutationObserver(function (n) { Array.from(n).forEach(function (n) { Array.from(n.addedNodes).forEach(function (n) { is$1.element(n) && matches$1(n, t) && new e(n, i); }); }); }); o.observe(document.body, { childList: !0, subtree: !0 }); } return r.map(function (t) { return new e(t, n); }); } }, { key: "enabled", get: function () { return "ontouchstart" in document.documentElement; } }]), e; }(); // ========================================================================== // Type checking utils // ========================================================================== const getConstructor = input => input !== null && typeof input !== 'undefined' ? input.constructor : null; const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor); const isNullOrUndefined = input => input === null || typeof input === 'undefined'; const isObject = input => getConstructor(input) === Object; const isNumber = input => getConstructor(input) === Number && !Number.isNaN(input); const isString = input => getConstructor(input) === String; const isBoolean = input => getConstructor(input) === Boolean; const isFunction = input => getConstructor(input) === Function; const isArray = input => Array.isArray(input); const isWeakMap = input => instanceOf(input, WeakMap); const isNodeList = input => instanceOf(input, NodeList); const isTextNode = input => getConstructor(input) === Text; const isEvent = input => instanceOf(input, Event); const isKeyboardEvent = input => instanceOf(input, KeyboardEvent); const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue); const isTrack = input => instanceOf(input, TextTrack) || !isNullOrUndefined(input) && isString(input.kind); const isPromise = input => instanceOf(input, Promise) && isFunction(input.then); const isElement = input => input !== null && typeof input === 'object' && input.nodeType === 1 && typeof input.style === 'object' && typeof input.ownerDocument === 'object'; const isEmpty = input => isNullOrUndefined(input) || (isString(input) || isArray(input) || isNodeList(input)) && !input.length || isObject(input) && !Object.keys(input).length; const isUrl = input => { // Accept a URL object if (instanceOf(input, window.URL)) { return true; } // Must be string from here if (!isString(input)) { return false; } // Add the protocol if required let string = input; if (!input.startsWith('http://') || !input.startsWith('https://')) { string = `http://${input}`; } try { return !isEmpty(new URL(string).hostname); } catch (_) { return false; } }; var is = { nullOrUndefined: isNullOrUndefined, object: isObject, number: isNumber, string: isString, boolean: isBoolean, function: isFunction, array: isArray, weakMap: isWeakMap, nodeList: isNodeList, element: isElement, textNode: isTextNode, event: isEvent, keyboardEvent: isKeyboardEvent, cue: isCue, track: isTrack, promise: isPromise, url: isUrl, empty: isEmpty }; // ========================================================================== const transitionEndEvent = (() => { const element = document.createElement('span'); const events = { WebkitTransition: 'webkitTransitionEnd', MozTransition: 'transitionend', OTransition: 'oTransitionEnd otransitionend', transition: 'transitionend' }; const type = Object.keys(events).find(event => element.style[event] !== undefined); return is.string(type) ? events[type] : false; })(); // Force repaint of element function repaint(element, delay) { setTimeout(() => { try { // eslint-disable-next-line no-param-reassign element.hidden = true; // eslint-disable-next-line no-unused-expressions element.offsetHeight; // eslint-disable-next-line no-param-reassign element.hidden = false; } catch (_) {// Do nothing } }, delay); } // ========================================================================== // Browser sniffing // Unfortunately, due to mixed support, UA sniffing is required // ========================================================================== const browser = { isIE: Boolean(window.document.documentMode), isEdge: window.navigator.userAgent.includes('Edge'), isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent), isIPhone: /(iPhone|iPod)/gi.test(navigator.platform), isIos: navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 || /(iPad|iPhone|iPod)/gi.test(navigator.platform) }; // ========================================================================== function cloneDeep(object) { return JSON.parse(JSON.stringify(object)); } // Get a nested value in an object function getDeep(object, path) { return path.split('.').reduce((obj, key) => obj && obj[key], object); } // Deep extend destination object with N more objects function extend(target = {}, ...sources) { if (!sources.length) { return target; } const source = sources.shift(); if (!is.object(source)) { return target; } Object.keys(source).forEach(key => { if (is.object(source[key])) { if (!Object.keys(target).includes(key)) { Object.assign(target, { [key]: {} }); } extend(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } }); return extend(target, ...sources); } // ========================================================================== function wrap(elements, wrapper) { // Convert `elements` to an array, if necessary. const targets = elements.length ? elements : [elements]; // Loops backwards to prevent having to clone the wrapper on the // first element (see `child` below). Array.from(targets).reverse().forEach((element, index) => { const child = index > 0 ? wrapper.cloneNode(true) : wrapper; // Cache the current parent and sibling. const parent = element.parentNode; const sibling = element.nextSibling; // Wrap the element (is automatically removed from its current // parent). child.appendChild(element); // If the element had a sibling, insert the wrapper before // the sibling to maintain the HTML structure; otherwise, just // append it to the parent. if (sibling) { parent.insertBefore(child, sibling); } else { parent.appendChild(child); } }); } // Set attributes function setAttributes(element, attributes) { if (!is.element(element) || is.empty(attributes)) { return; } // Assume null and undefined attributes should be left out, // Setting them would otherwise convert them to "null" and "undefined" Object.entries(attributes).filter(([, value]) => !is.nullOrUndefined(value)).forEach(([key, value]) => element.setAttribute(key, value)); } // Create a DocumentFragment function createElement(type, attributes, text) { // Create a new const element = document.createElement(type); // Set all passed attributes if (is.object(attributes)) { setAttributes(element, attributes); } // Add text node if (is.string(text)) { element.innerText = text; } // Return built element return element; } // Inaert an element after another function insertAfter(element, target) { if (!is.element(element) || !is.element(target)) { return; } target.parentNode.insertBefore(element, target.nextSibling); } // Insert a DocumentFragment function insertElement(type, parent, attributes, text) { if (!is.element(parent)) { return; } parent.appendChild(createElement(type, attributes, text)); } // Remove element(s) function removeElement(element) { if (is.nodeList(element) || is.array(element)) { Array.from(element).forEach(removeElement); return; } if (!is.element(element) || !is.element(element.parentNode)) { return; } element.parentNode.removeChild(element); } // Remove all child elements function emptyElement(element) { if (!is.element(element)) { return; } let { length } = element.childNodes; while (length > 0) { element.removeChild(element.lastChild); length -= 1; } } // Replace element function replaceElement(newChild, oldChild) { if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) { return null; } oldChild.parentNode.replaceChild(newChild, oldChild); return newChild; } // Get an attribute object from a string selector function getAttributesFromSelector(sel, existingAttributes) { // For example: // '.test' to { class: 'test' } // '#test' to { id: 'test' } // '[data-test="test"]' to { 'data-test': 'test' } if (!is.string(sel) || is.empty(sel)) { return {}; } const attributes = {}; const existing = extend({}, existingAttributes); sel.split(',').forEach(s => { // Remove whitespace const selector = s.trim(); const className = selector.replace('.', ''); const stripped = selector.replace(/[[\]]/g, ''); // Get the parts and value const parts = stripped.split('='); const [key] = parts; const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; // Get the first character const start = selector.charAt(0); switch (start) { case '.': // Add to existing classname if (is.string(existing.class)) { attributes.class = `${existing.class} ${className}`; } else { attributes.class = className; } break; case '#': // ID selector attributes.id = selector.replace('#', ''); break; case '[': // Attribute selector attributes[key] = value; break; } }); return extend(existing, attributes); } // Toggle hidden function toggleHidden(element, hidden) { if (!is.element(element)) { return; } let hide = hidden; if (!is.boolean(hide)) { hide = !element.hidden; } // eslint-disable-next-line no-param-reassign element.hidden = hide; } // Mirror Element.classList.toggle, with IE compatibility for "force" argument function toggleClass(element, className, force) { if (is.nodeList(element)) { return Array.from(element).map(e => toggleClass(e, className, force)); } if (is.element(element)) { let method = 'toggle'; if (typeof force !== 'undefined') { method = force ? 'add' : 'remove'; } element.classList[method](className); return element.classList.contains(className); } return false; } // Has class name function hasClass(element, className) { return is.element(element) && element.classList.contains(className); } // Element matches selector function matches(element, selector) { const { prototype } = Element; function match() { return Array.from(document.querySelectorAll(selector)).includes(this); } const method = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match; return method.call(element, selector); } // Closest ancestor element matching selector (also tests element itself) function closest$1(element, selector) { const { prototype } = Element; // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill function closestElement() { let el = this; do { if (matches.matches(el, selector)) return el; el = el.parentElement || el.parentNode; } while (el !== null && el.nodeType === 1); return null; } const method = prototype.closest || closestElement; return method.call(element, selector); } // Find all elements function getElements(selector) { return this.elements.container.querySelectorAll(selector); } // Find a single element function getElement(selector) { return this.elements.container.querySelector(selector); } // Set focus and tab focus class function setFocus(element = null, tabFocus = false) { if (!is.element(element)) { return; } // Set regular focus element.focus({ preventScroll: true }); // If we want to mimic keyboard focus via tab if (tabFocus) { toggleClass(element, this.config.classNames.tabFocus); } } // ========================================================================== const defaultCodecs = { 'audio/ogg': 'vorbis', 'audio/wav': '1', 'video/webm': 'vp8, vorbis', 'video/mp4': 'avc1.42E01E, mp4a.40.2', 'video/ogg': 'theora' }; // Check for feature support const support = { // Basic support audio: 'canPlayType' in document.createElement('audio'), video: 'canPlayType' in document.createElement('video'), // Check for support // Basic functionality vs full UI check(type, provider, playsinline) { const canPlayInline = browser.isIPhone && playsinline && support.playsinline; const api = support[type] || provider !== 'html5'; const ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline); return { api, ui }; }, // Picture-in-picture support // Safari & Chrome only currently pip: (() => { if (browser.isIPhone) { return false; } // Safari // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls if (is.function(createElement('video').webkitSetPresentationMode)) { return true; } // Chrome // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) { return true; } return false; })(), // Airplay support // Safari only currently airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent), // Inline playback support // https://webkit.org/blog/6784/new-video-policies-for-ios/ playsinline: 'playsInline' in document.createElement('video'), // Check for mime type support against a player instance // Credits: http://diveintohtml5.info/everything.html // Related: http://www.leanbackplayer.com/test/h5mt.html mime(input) { if (is.empty(input)) { return false; } const [mediaType] = input.split('/'); let type = input; // Verify we're using HTML5 and there's no media type mismatch if (!this.isHTML5 || mediaType !== this.type) { return false; } // Add codec if required if (Object.keys(defaultCodecs).includes(type)) { type += `; codecs="${defaultCodecs[input]}"`; } try { return Boolean(type && this.media.canPlayType(type).replace(/no/, '')); } catch (_) { return false; } }, // Check for textTracks support textTracks: 'textTracks' in document.createElement('video'), // Sliders rangeInput: (() => { const range = document.createElement('input'); range.type = 'range'; return range.type === 'range'; })(), // Touch // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event touch: 'ontouchstart' in document.documentElement, // Detect transitions support transitions: transitionEndEvent !== false, // Reduced motion iOS & MacOS setting // https://webkit.org/blog/7551/responsive-design-for-motion/ reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches }; // ========================================================================== // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md // https://www.youtube.com/watch?v=NPM6172J22g const supportsPassiveListeners = (() => { // Test via a getter in the options object to see if the passive property is accessed let supported = false; try { const options = Object.defineProperty({}, 'passive', { get() { supported = true; return null; } }); window.addEventListener('test', null, options); window.removeEventListener('test', null, options); } catch (_) {// Do nothing } return supported; })(); // Toggle event listener function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) { // Bail if no element, event, or callback if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) { return; } // Allow multiple events const events = event.split(' '); // Build options // Default to just the capture boolean for browsers with no passive listener support let options = capture; // If passive events listeners are supported if (supportsPassiveListeners) { options = { // Whether the listener can be passive (i.e. default never prevented) passive, // Whether the listener is a capturing listener or not capture }; } // If a single node is passed, bind the event listener events.forEach(type => { if (this && this.eventListeners && toggle) { // Cache event listener this.eventListeners.push({ element, type, callback, options }); } element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); }); } // Bind event handler function on(element, events = '', callback, passive = true, capture = false) { toggleListener.call(this, element, events, callback, true, passive, capture); } // Unbind event handler function off(element, events = '', callback, passive = true, capture = false) { toggleListener.call(this, element, events, callback, false, passive, capture); } // Bind once-only event handler function once(element, events = '', callback, passive = true, capture = false) { const onceCallback = (...args) => { off(element, events, onceCallback, passive, capture); callback.apply(this, args); }; toggleListener.call(this, element, events, onceCallback, true, passive, capture); } // Trigger event function triggerEvent(element, type = '', bubbles = false, detail = {}) { // Bail if no element if (!is.element(element) || is.empty(type)) { return; } // Create and dispatch the event const event = new CustomEvent(type, { bubbles, detail: { ...detail, plyr: this } }); // Dispatch the event element.dispatchEvent(event); } // Unbind all cached event listeners function unbindListeners() { if (this && this.eventListeners) { this.eventListeners.forEach(item => { const { element, type, callback, options } = item; element.removeEventListener(type, callback, options); }); this.eventListeners = []; } } // Run method when / if player is ready function ready() { return new Promise(resolve => this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)).then(() => {}); } /** * Silence a Promise-like object. * This is useful for avoiding non-harmful, but potentially confusing "uncaught * play promise" rejection error messages. * @param {Object} value An object that may or may not be `Promise`-like. */ function silencePromise(value) { if (is.promise(value)) { value.then(null, () => {}); } } // ========================================================================== function dedupe(array) { if (!is.array(array)) { return array; } return array.filter((item, index) => array.indexOf(item) === index); } // Get the closest value in an array function closest(array, value) { if (!is.array(array) || !array.length) { return null; } return array.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev); } // ========================================================================== function supportsCSS(declaration) { if (!window || !window.CSS) { return false; } return window.CSS.supports(declaration); } // Standard/common aspect ratios 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, [x / y]: [x, y] }), {}); // Validate an aspect ratio function validateAspectRatio(input) { if (!is.array(input) && (!is.string(input) || !input.includes(':'))) { return false; } const ratio = is.array(input) ? input : input.split(':'); return ratio.map(Number).every(is.number); } // Reduce an aspect ratio to it's lowest form function reduceAspectRatio(ratio) { if (!is.array(ratio) || !ratio.every(is.number)) { return null; } const [width, height] = ratio; const getDivider = (w, h) => h === 0 ? w : getDivider(h, w % h); const divider = getDivider(width, height); return [width / divider, height / divider]; } // Calculate an aspect ratio function getAspectRatio(input) { const parse = ratio => validateAspectRatio(ratio) ? ratio.split(':').map(Number) : null; // Try provided ratio let ratio = parse(input); // Get from config if (ratio === null) { ratio = parse(this.config.ratio); } // Get from embed if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) { ({ ratio } = this.embed); } // Get from HTML5 video if (ratio === null && this.isHTML5) { const { videoWidth, videoHeight } = this.media; ratio = [videoWidth, videoHeight]; } return reduceAspectRatio(ratio); } // Set aspect ratio for responsive container function setAspectRatio(input) { if (!this.isVideo) { return {}; } const { wrapper } = this.elements; const ratio = getAspectRatio.call(this, input); if (!is.array(ratio)) { return {}; } const [x, y] = reduceAspectRatio(ratio); const useNative = supportsCSS(`aspect-ratio: ${x}/${y}`); const padding = 100 / x * y; if (useNative) { wrapper.style.aspectRatio = `${x}/${y}`; } else { wrapper.style.paddingBottom = `${padding}%`; } // For Vimeo we have an extra
to hide the standard controls and UI if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) { const height = 100 / this.media.offsetWidth * parseInt(window.getComputedStyle(this.media).paddingBottom, 10); const offset = (height - padding) / (height / 50); if (this.fullscreen.active) { wrapper.style.paddingBottom = null; } else { this.media.style.transform = `translateY(-${offset}%)`; } } else if (this.isHTML5) { wrapper.classList.add(this.config.classNames.videoFixedRatio); } return { padding, ratio }; } // Round an aspect ratio to closest standard ratio function roundAspectRatio(x, y, tolerance = 0.05) { const ratio = x / y; const closestRatio = closest(Object.keys(standardRatios), ratio); // Check match is within tolerance if (Math.abs(closestRatio - ratio) <= tolerance) { return standardRatios[closestRatio]; } // No match return [x, y]; } // Get the size of the viewport // https://stackoverflow.com/questions/1248081/how-to-get-the-browser-viewport-dimensions function getViewportSize() { const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); return [width, height]; } // ========================================================================== const html5 = { getSources() { if (!this.isHTML5) { return []; } const sources = Array.from(this.media.querySelectorAll('source')); // Filter out unsupported sources (if type is specified) return sources.filter(source => { const type = source.getAttribute('type'); if (is.empty(type)) { return true; } return support.mime.call(this, type); }); }, // Get quality levels getQualityOptions() { // Whether we're forcing all options (e.g. for streaming) if (this.config.quality.forced) { return this.config.quality.options; } // Get sizes from elements return html5.getSources.call(this).map(source => Number(source.getAttribute('data-res'))).filter(Boolean); }, setup() { if (!this.isHTML5) { return; } const player = this; // Set speed options from config player.options.speed = player.config.speed.options; // Set aspect ratio if fixed if (!is.empty(this.config.ratio)) { setAspectRatio.call(player); } // Quality Object.defineProperty(player.media, 'quality', { get() { // Get sources const sources = html5.getSources.call(player); const source = sources.find(s => s.getAttribute('src') === player.source); // Return size, if match is found return source && Number(source.getAttribute('data-res')); }, set(input) { if (player.quality === input) { return; } // If we're using an external handler... if (player.config.quality.forced && is.function(player.config.quality.onChange)) { player.config.quality.onChange(input); } else { // Get sources const sources = html5.getSources.call(player); // Get first match for requested size const source = sources.find(s => Number(s.getAttribute('data-res')) === input); // No matching source found if (!source) { return; } // Get current state const { currentTime, paused, preload, readyState, playbackRate } = player.media; // Set new source player.media.src = source.getAttribute('src'); // Prevent loading if preload="none" and the current source isn't loaded (#1044) if (preload !== 'none' || readyState) { // Restore time player.once('loadedmetadata', () => { player.speed = playbackRate; player.currentTime = currentTime; // Resume playing if (!paused) { silencePromise(player.play()); } }); // Load new source player.media.load(); } } // Trigger change event triggerEvent.call(player, player.media, 'qualitychange', false, { quality: input }); } }); }, // Cancel current network requests // See https://github.com/sampotts/plyr/issues/174 cancelRequests() { if (!this.isHTML5) { return; } // Remove child sources removeElement(html5.getSources.call(this)); // Set blank video src attribute // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection this.media.setAttribute('src', this.config.blankVideo); // Load the new empty source // This will cancel existing requests // See https://github.com/sampotts/plyr/issues/174 this.media.load(); // Debugging this.debug.log('Cancelled network requests'); } }; // ========================================================================== function generateId(prefix) { return `${prefix}-${Math.floor(Math.random() * 10000)}`; } // Format string function format(input, ...args) { if (is.empty(input)) { return input; } return input.toString().replace(/{(\d+)}/g, (match, i) => args[i].toString()); } // Get percentage function getPercentage(current, max) { if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { return 0; } return (current / max * 100).toFixed(2); } // Replace all occurances of a string in a string const replaceAll = (input = '', find = '', replace = '') => input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString()); // Convert to title case const toTitleCase = (input = '') => input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase()); // Convert string to pascalCase function toPascalCase(input = '') { let string = input.toString(); // Convert kebab case string = replaceAll(string, '-', ' '); // Convert snake case string = replaceAll(string, '_', ' '); // Convert to title case string = toTitleCase(string); // Convert to pascal case return replaceAll(string, ' ', ''); } // Convert string to pascalCase function toCamelCase(input = '') { let string = input.toString(); // Convert to pascal case string = toPascalCase(string); // Convert first character to lowercase return string.charAt(0).toLowerCase() + string.slice(1); } // Remove HTML from a string function stripHTML(source) { const fragment = document.createDocumentFragment(); const element = document.createElement('div'); fragment.appendChild(element); element.innerHTML = source; return fragment.firstChild.innerText; } // Like outerHTML, but also works for DocumentFragment function getHTML(element) { const wrapper = document.createElement('div'); wrapper.appendChild(element); return wrapper.innerHTML; } // ========================================================================== const resources = { pip: 'PIP', airplay: 'AirPlay', html5: 'HTML5', vimeo: 'Vimeo', youtube: 'YouTube' }; const i18n = { get(key = '', config = {}) { if (is.empty(key) || is.empty(config)) { return ''; } let string = getDeep(config.i18n, key); if (is.empty(string)) { if (Object.keys(resources).includes(key)) { return resources[key]; } return ''; } const replace = { '{seektime}': config.seekTime, '{title}': config.title }; Object.entries(replace).forEach(([k, v]) => { string = replaceAll(string, k, v); }); return string; } }; class Storage { constructor(player) { _defineProperty$1(this, "get", key => { if (!Storage.supported || !this.enabled) { return null; } const store = window.localStorage.getItem(this.key); if (is.empty(store)) { return null; } const json = JSON.parse(store); return is.string(key) && key.length ? json[key] : json; }); _defineProperty$1(this, "set", object => { // Bail if we don't have localStorage support or it's disabled if (!Storage.supported || !this.enabled) { return; } // Can only store objectst if (!is.object(object)) { return; } // Get current storage let storage = this.get(); // Default to empty object if (is.empty(storage)) { storage = {}; } // Update the working copy of the values extend(storage, object); // Update storage try { window.localStorage.setItem(this.key, JSON.stringify(storage)); } catch (_) {// Do nothing } }); this.enabled = player.config.storage.enabled; this.key = player.config.storage.key; } // Check for actual support (see if we can use it) static get supported() { try { if (!('localStorage' in window)) { return false; } const test = '___test'; // Try to use it (it might be disabled, e.g. user is in private mode) // see: https://github.com/sampotts/plyr/issues/131 window.localStorage.setItem(test, test); window.localStorage.removeItem(test); return true; } catch (_) { return false; } } } // ========================================================================== // Fetch wrapper // Using XHR to avoid issues with older browsers // ========================================================================== function fetch(url, responseType = 'text') { return new Promise((resolve, reject) => { try { const request = new XMLHttpRequest(); // Check for CORS support if (!('withCredentials' in request)) { return; } request.addEventListener('load', () => { if (responseType === 'text') { try { resolve(JSON.parse(request.responseText)); } catch (_) { resolve(request.responseText); } } else { resolve(request.response); } }); request.addEventListener('error', () => { throw new Error(request.status); }); request.open('GET', url, true); // Set the required response type request.responseType = responseType; request.send(); } catch (error) { reject(error); } }); } // ========================================================================== function loadSprite(url, id) { if (!is.string(url)) { return; } const prefix = 'cache'; const hasId = is.string(id); let isCached = false; const exists = () => document.getElementById(id) !== null; const update = (container, data) => { // eslint-disable-next-line no-param-reassign container.innerHTML = data; // Check again incase of race condition if (hasId && exists()) { return; } // Inject the SVG to the body document.body.insertAdjacentElement('afterbegin', container); }; // Only load once if ID set if (!hasId || !exists()) { const useStorage = Storage.supported; // Create container const container = document.createElement('div'); container.setAttribute('hidden', ''); if (hasId) { container.setAttribute('id', id); } // Check in cache if (useStorage) { const cached = window.localStorage.getItem(`${prefix}-${id}`); isCached = cached !== null; if (isCached) { const data = JSON.parse(cached); update(container, data.content); } } // Get the sprite fetch(url).then(result => { if (is.empty(result)) { return; } if (useStorage) { try { window.localStorage.setItem(`${prefix}-${id}`, JSON.stringify({ content: result })); } catch (_) {// Do nothing } } update(container, result); }).catch(() => {}); } } // ========================================================================== const getHours = value => Math.trunc(value / 60 / 60 % 60, 10); const getMinutes = value => Math.trunc(value / 60 % 60, 10); const getSeconds = value => Math.trunc(value % 60, 10); // Format time to UI friendly string function formatTime(time = 0, displayHours = false, inverted = false) { // Bail if the value isn't a number if (!is.number(time)) { return formatTime(undefined, displayHours, inverted); } // Format time component to add leading zero const format = value => `0${value}`.slice(-2); // Breakdown to hours, mins, secs let hours = getHours(time); const mins = getMinutes(time); const secs = getSeconds(time); // Do we need to display hours? if (displayHours || hours > 0) { hours = `${hours}:`; } else { hours = ''; } // Render return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; } // ========================================================================== const controls = { // Get icon URL getIconUrl() { const url = new URL(this.config.iconUrl, window.location); const host = window.location.host ? window.location.host : window.top.location.host; const cors = url.host !== host || browser.isIE && !window.svg4everybody; return { url: this.config.iconUrl, cors }; }, // Find the UI controls findElements() { try { this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); // Buttons this.elements.buttons = { play: getElements.call(this, this.config.selectors.buttons.play), pause: getElement.call(this, this.config.selectors.buttons.pause), restart: getElement.call(this, this.config.selectors.buttons.restart), rewind: getElement.call(this, this.config.selectors.buttons.rewind), fastForward: getElement.call(this, this.config.selectors.buttons.fastForward), mute: getElement.call(this, this.config.selectors.buttons.mute), pip: getElement.call(this, this.config.selectors.buttons.pip), airplay: getElement.call(this, this.config.selectors.buttons.airplay), settings: getElement.call(this, this.config.selectors.buttons.settings), captions: getElement.call(this, this.config.selectors.buttons.captions), fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen) }; // Progress this.elements.progress = getElement.call(this, this.config.selectors.progress); // Inputs this.elements.inputs = { seek: getElement.call(this, this.config.selectors.inputs.seek), volume: getElement.call(this, this.config.selectors.inputs.volume) }; // Display this.elements.display = { buffer: getElement.call(this, this.config.selectors.display.buffer), currentTime: getElement.call(this, this.config.selectors.display.currentTime), duration: getElement.call(this, this.config.selectors.display.duration) }; // Seek tooltip if (is.element(this.elements.progress)) { this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`); } return true; } catch (error) { // Log it this.debug.warn('It looks like there is a problem with your custom controls HTML', error); // Restore native video controls this.toggleNativeControls(true); return false; } }, // Create icon createIcon(type, attributes) { const namespace = 'http://www.w3.org/2000/svg'; const iconUrl = controls.getIconUrl.call(this); const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`; // Create const icon = document.createElementNS(namespace, 'svg'); setAttributes(icon, extend(attributes, { 'aria-hidden': 'true', focusable: 'false' })); // Create the to reference sprite const use = document.createElementNS(namespace, 'use'); const path = `${iconPath}-${type}`; // Set `href` attributes // https://github.com/sampotts/plyr/issues/460 // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href if ('href' in use) { use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path); } // Always set the older attribute even though it's "deprecated" (it'll be around for ages) use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); // Add to icon.appendChild(use); return icon; }, // Create hidden text label createLabel(key, attr = {}) { const text = i18n.get(key, this.config); const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') }; return createElement('span', attributes, text); }, // Create a badge createBadge(text) { if (is.empty(text)) { return null; } const badge = createElement('span', { class: this.config.classNames.menu.value }); badge.appendChild(createElement('span', { class: this.config.classNames.menu.badge }, text)); return badge; }, // Create a
if needed if (is.empty(source)) { source = player.media.getAttribute(player.config.attributes.embed.id); // hash can also be set as attribute on the
hash = player.media.getAttribute(player.config.attributes.embed.hash); } else { hash = parseHash(source); } const hashParam = hash ? { h: hash } : {}; // If the owner has a pro or premium account then we can hide controls etc if (premium) { Object.assign(frameParams, { controls: false, sidedock: false }); } // Get Vimeo params for the iframe const params = buildUrlParams({ loop: player.config.loop.active, autoplay: player.autoplay, muted: player.muted, gesture: 'media', playsinline: !this.config.fullscreen.iosNative, // hash has to be added to iframe-URL ...hashParam, ...frameParams }); const id = parseId$1(source); // Build an iframe const iframe = createElement('iframe'); const src = format(player.config.urls.vimeo.iframe, id, params); iframe.setAttribute('src', src); iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('allow', ['autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media', 'accelerometer', 'gyroscope'].join('; ')); // Set the referrer policy if required if (!is.empty(referrerPolicy)) { iframe.setAttribute('referrerPolicy', referrerPolicy); } // Inject the package if (premium || !config.customControls) { iframe.setAttribute('data-poster', player.poster); player.media = replaceElement(iframe, player.media); } else { const wrapper = createElement('div', { class: player.config.classNames.embedContainer, 'data-poster': player.poster }); wrapper.appendChild(iframe); player.media = replaceElement(wrapper, player.media); } // Get poster image if (!config.customControls) { fetch(format(player.config.urls.vimeo.api, src)).then(response => { if (is.empty(response) || !response.thumbnail_url) { return; } // Set and show poster ui.setPoster.call(player, response.thumbnail_url).catch(() => {}); }); } // Setup instance // https://github.com/vimeo/player.js player.embed = new window.Vimeo.Player(iframe, { autopause: player.config.autopause, muted: player.muted }); player.media.paused = true; player.media.currentTime = 0; // Disable native text track rendering if (player.supported.ui) { player.embed.disableTextTrack(); } // Create a faux HTML5 API using the Vimeo API player.media.play = () => { assurePlaybackState$1.call(player, true); return player.embed.play(); }; player.media.pause = () => { assurePlaybackState$1.call(player, false); return player.embed.pause(); }; player.media.stop = () => { player.pause(); player.currentTime = 0; }; // Seeking let { currentTime } = player.media; Object.defineProperty(player.media, 'currentTime', { get() { return currentTime; }, set(time) { // Vimeo will automatically play on seek if the video hasn't been played before // Get current paused state and volume etc const { embed, media, paused, volume } = player; const restorePause = paused && !embed.hasPlayed; // Set seeking state and trigger event media.seeking = true; triggerEvent.call(player, media, 'seeking'); // If paused, mute until seek is complete Promise.resolve(restorePause && embed.setVolume(0)) // Seek .then(() => embed.setCurrentTime(time)) // Restore paused .then(() => restorePause && embed.pause()) // Restore volume .then(() => restorePause && embed.setVolume(volume)).catch(() => {// Do nothing }); } }); // Playback speed let speed = player.config.speed.selected; Object.defineProperty(player.media, 'playbackRate', { get() { return speed; }, set(input) { player.embed.setPlaybackRate(input).then(() => { speed = input; triggerEvent.call(player, player.media, 'ratechange'); }).catch(() => { // Cannot set Playback Rate, Video is probably not on Pro account player.options.speed = [1]; }); } }); // Volume let { volume } = player.config; Object.defineProperty(player.media, 'volume', { get() { return volume; }, set(input) { player.embed.setVolume(input).then(() => { volume = input; triggerEvent.call(player, player.media, 'volumechange'); }); } }); // Muted let { muted } = player.config; Object.defineProperty(player.media, 'muted', { get() { return muted; }, set(input) { const toggle = is.boolean(input) ? input : false; player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => { muted = toggle; triggerEvent.call(player, player.media, 'volumechange'); }); } }); // Loop let { loop } = player.config; Object.defineProperty(player.media, 'loop', { get() { return loop; }, set(input) { const toggle = is.boolean(input) ? input : player.config.loop.active; player.embed.setLoop(toggle).then(() => { loop = toggle; }); } }); // Source let currentSrc; player.embed.getVideoUrl().then(value => { currentSrc = value; controls.setDownloadUrl.call(player); }).catch(error => { this.debug.warn(error); }); Object.defineProperty(player.media, 'currentSrc', { get() { return currentSrc; } }); // Ended Object.defineProperty(player.media, 'ended', { get() { return player.currentTime === player.duration; } }); // Set aspect ratio based on video size Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => { const [width, height] = dimensions; player.embed.ratio = roundAspectRatio(width, height); setAspectRatio.call(this); }); // Set autopause player.embed.setAutopause(player.config.autopause).then(state => { player.config.autopause = state; }); // Get title player.embed.getVideoTitle().then(title => { player.config.title = title; ui.setTitle.call(this); }); // Get current time player.embed.getCurrentTime().then(value => { currentTime = value; triggerEvent.call(player, player.media, 'timeupdate'); }); // Get duration player.embed.getDuration().then(value => { player.media.duration = value; triggerEvent.call(player, player.media, 'durationchange'); }); // Get captions player.embed.getTextTracks().then(tracks => { player.media.textTracks = tracks; captions.setup.call(player); }); player.embed.on('cuechange', ({ cues = [] }) => { const strippedCues = cues.map(cue => stripHTML(cue.text)); captions.updateCues.call(player, strippedCues); }); player.embed.on('loaded', () => { // Assure state and events are updated on autoplay player.embed.getPaused().then(paused => { assurePlaybackState$1.call(player, !paused); if (!paused) { triggerEvent.call(player, player.media, 'playing'); } }); if (is.element(player.embed.element) && player.supported.ui) { const frame = player.embed.element; // Fix keyboard focus issues // https://github.com/sampotts/plyr/issues/317 frame.setAttribute('tabindex', -1); } }); player.embed.on('bufferstart', () => { triggerEvent.call(player, player.media, 'waiting'); }); player.embed.on('bufferend', () => { triggerEvent.call(player, player.media, 'playing'); }); player.embed.on('play', () => { assurePlaybackState$1.call(player, true); triggerEvent.call(player, player.media, 'playing'); }); player.embed.on('pause', () => { assurePlaybackState$1.call(player, false); }); player.embed.on('timeupdate', data => { player.media.seeking = false; currentTime = data.seconds; triggerEvent.call(player, player.media, 'timeupdate'); }); player.embed.on('progress', data => { player.media.buffered = data.percent; triggerEvent.call(player, player.media, 'progress'); // Check all loaded if (parseInt(data.percent, 10) === 1) { triggerEvent.call(player, player.media, 'canplaythrough'); } // Get duration as if we do it before load, it gives an incorrect value // https://github.com/sampotts/plyr/issues/891 player.embed.getDuration().then(value => { if (value !== player.media.duration) { player.media.duration = value; triggerEvent.call(player, player.media, 'durationchange'); } }); }); player.embed.on('seeked', () => { player.media.seeking = false; triggerEvent.call(player, player.media, 'seeked'); }); player.embed.on('ended', () => { player.media.paused = true; triggerEvent.call(player, player.media, 'ended'); }); player.embed.on('error', detail => { player.media.error = detail; triggerEvent.call(player, player.media, 'error'); }); // Rebuild UI if (config.customControls) { setTimeout(() => ui.build.call(player), 0); } } }; // ========================================================================== function parseId(url) { if (is.empty(url)) { return null; } const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; return url.match(regex) ? RegExp.$2 : url; } // Set playback state and trigger change (only on actual change) function assurePlaybackState(play) { if (play && !this.embed.hasPlayed) { this.embed.hasPlayed = true; } if (this.media.paused === play) { this.media.paused = !play; triggerEvent.call(this, this.media, play ? 'play' : 'pause'); } } function getHost(config) { if (config.noCookie) { return 'https://www.youtube-nocookie.com'; } if (window.location.protocol === 'http:') { return 'http://www.youtube.com'; } // Use YouTube's default return undefined; } const youtube = { setup() { // Add embed class for responsive toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Setup API if (is.object(window.YT) && is.function(window.YT.Player)) { youtube.ready.call(this); } else { // Reference current global callback const callback = window.onYouTubeIframeAPIReady; // Set callback to process queue window.onYouTubeIframeAPIReady = () => { // Call global callback if set if (is.function(callback)) { callback(); } youtube.ready.call(this); }; // Load the SDK loadScript(this.config.urls.youtube.sdk).catch(error => { this.debug.warn('YouTube API failed to load', error); }); } }, // Get the media title getTitle(videoId) { const url = format(this.config.urls.youtube.api, videoId); fetch(url).then(data => { if (is.object(data)) { const { title, height, width } = data; // Set title this.config.title = title; ui.setTitle.call(this); // Set aspect ratio this.embed.ratio = roundAspectRatio(width, height); } setAspectRatio.call(this); }).catch(() => { // Set aspect ratio setAspectRatio.call(this); }); }, // API ready ready() { const player = this; const config = player.config.youtube; // Ignore already setup (race condition) const currentId = player.media && player.media.getAttribute('id'); if (!is.empty(currentId) && currentId.startsWith('youtube-')) { return; } // Get the source URL or ID let source = player.media.getAttribute('src'); // Get from
if needed if (is.empty(source)) { source = player.media.getAttribute(this.config.attributes.embed.id); } // Replace the