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 _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : String(i); } function _defineProperty$1(obj, key, value) { key = _toPropertyKey(key); 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 => typeof 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 }; // ========================================================================== // Animation utils // ========================================================================== 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 isIE = Boolean(window.document.documentMode); const isEdge = /Edge/g.test(navigator.userAgent); const isWebKit = 'WebkitAppearance' in document.documentElement.style && !/Edge/g.test(navigator.userAgent); const isIPhone = /iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1; // navigator.platform may be deprecated but this check is still required const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; const isIos = /iPad|iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1; var browser = { isIE, isEdge, isWebKit, isIPhone, isIPadOS, isIos }; // ========================================================================== // Object utils // ========================================================================== // Clone nested objects 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); } // ========================================================================== // Element utils // ========================================================================== // Wrap an element 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; } // Insert 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, focusVisible = false) { if (!is.element(element)) return; // Set regular focus element.focus({ preventScroll: true, focusVisible }); } // ========================================================================== // Plyr support checks // ========================================================================== // Default codecs for checking mimetype support 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) { const api = support[type] || provider !== 'html5'; const ui = api && support.rangeInput; return { api, ui }; }, // Picture-in-picture support // Safari & Chrome only currently pip: (() => { // While iPhone's support picture-in-picture for some apps, seemingly Safari isn't one of them // It will throw the following error when trying to enter picture-in-picture // `NotSupportedError: The Picture-in-Picture mode is not supported.` 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 }; // ========================================================================== // Event utils // ========================================================================== // Check for passive event listener support // 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, () => {}); } } // ========================================================================== // Array utils // ========================================================================== // Remove duplicates in an array 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); } // ========================================================================== // Style utils // ========================================================================== // Check support for a CSS declaration 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]; } // ========================================================================== // Plyr HTML5 helpers // ========================================================================== 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'); } }; // ========================================================================== // String utils // ========================================================================== // Generate a random ID 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, (_, 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 occurrences 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.slice(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; } // ========================================================================== // Plyr internationalization // ========================================================================== // Skip i18n for abbreviations and brand names 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); } }); } // ========================================================================== // Sprite loader // ========================================================================== // Load an external SVG sprite 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(() => {}); } } // ========================================================================== // Time utils // ========================================================================== // Time helpers 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)}`; } // ========================================================================== // Plyr controls // TODO: This needs to be split into smaller files and cleaned up // ========================================================================== // TODO: Don't export a massive object - break down and create class 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
`); } // Set position tipElement.style.left = `${percent}%`; // Show/hide the tooltip // If the event is a moues in/out and percentage is inside bounds if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { toggle(event.type === 'mouseenter'); } }, // Handle time change event timeUpdate(event) { // Only invert if only one time element is displayed and used for both duration and currentTime const invert = !is.element(this.elements.display.duration) && this.config.invertTime; // Duration controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert); // Ignore updates while seeking if (event && event.type === 'timeupdate' && this.media.seeking) { return; } // Playing progress controls.updateProgress.call(this, event); }, // Show the duration on metadataloaded or durationchange events durationUpdate() { // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false if (!this.supported.ui || !this.config.invertTime && this.currentTime) { return; } // 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. // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415 // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062 // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338 if (this.duration >= 2 ** 32) { toggleHidden(this.elements.display.currentTime, true); toggleHidden(this.elements.progress, true); return; } // Update ARIA values if (is.element(this.elements.inputs.seek)) { this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration); } // If there's a spot to display duration const hasDuration = is.element(this.elements.display.duration); // If there's only one time display, display duration there if (!hasDuration && this.config.displayDuration && this.paused) { controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration); } // If there's a duration element, update content if (hasDuration) { controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); } if (this.config.markers.enabled) { controls.setMarkers.call(this); } // Update the tooltip (if visible) controls.updateSeekTooltip.call(this); }, // Hide/show a tab toggleMenuButton(setting, toggle) { toggleHidden(this.elements.settings.buttons[setting], !toggle); }, // Update the selected setting updateSetting(setting, container, input) { const pane = this.elements.settings.panels[setting]; let value = null; let list = container; if (setting === 'captions') { value = this.currentTrack; } else { value = !is.empty(input) ? input : this[setting]; // Get default if (is.empty(value)) { value = this.config[setting].default; } // Unsupported value if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) { this.debug.warn(`Unsupported value of '${value}' for ${setting}`); return; } // Disabled value if (!this.config[setting].options.includes(value)) { this.debug.warn(`Disabled value of '${value}' for ${setting}`); return; } } // Get the list if we need to if (!is.element(list)) { list = pane && pane.querySelector('[role="menu"]'); } // If there's no list it means it's not been rendered... if (!is.element(list)) { return; } // Update the label const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`); label.innerHTML = controls.getLabel.call(this, setting, value); // Find the radio option and check it const target = list && list.querySelector(`[value="${value}"]`); if (is.element(target)) { target.checked = true; } }, // Translate a value into a nice label getLabel(setting, value) { switch (setting) { case 'speed': return value === 1 ? i18n.get('normal', this.config) : `${value}×`; case 'quality': if (is.number(value)) { const label = i18n.get(`qualityLabel.${value}`, this.config); if (!label.length) { return `${value}p`; } return label; } return toTitleCase(value); case 'captions': return captions.getLabel.call(this); default: return null; } }, // Set the quality menu setQualityMenu(options) { // Menu required if (!is.element(this.elements.settings.panels.quality)) { return; } const type = 'quality'; const list = this.elements.settings.panels.quality.querySelector('[role="menu"]'); // Set options if passed and filter based on uniqueness and config if (is.array(options)) { this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality)); } // Toggle the pane and tab const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1; controls.toggleMenuButton.call(this, type, toggle); // Empty the menu emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); // If we're hiding, nothing more to do if (!toggle) { return; } // Get the badge HTML for HD, 4K etc const getBadge = quality => { const label = i18n.get(`qualityBadge.${quality}`, this.config); if (!label.length) { return null; } return controls.createBadge.call(this, label); }; // Sort options by the config and then render options this.options.quality.sort((a, b) => { const sorting = this.config.quality.options; return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1; }).forEach(quality => { controls.createMenuItem.call(this, { value: quality, list, type, title: controls.getLabel.call(this, 'quality', quality), badge: getBadge(quality) }); }); controls.updateSetting.call(this, type, list); }, // Set the looping options /* setLoopMenu() { // Menu required if (!is.element(this.elements.settings.panels.loop)) { return; } const options = ['start', 'end', 'all', 'reset']; const list = this.elements.settings.panels.loop.querySelector('[role="menu"]'); // Show the pane and tab toggleHidden(this.elements.settings.buttons.loop, false); toggleHidden(this.elements.settings.panels.loop, false); // Toggle the pane and tab const toggle = !is.empty(this.loop.options); controls.toggleMenuButton.call(this, 'loop', toggle); // Empty the menu emptyElement(list); options.forEach(option => { const item = createElement('li'); const button = createElement( 'button', extend(getAttributesFromSelector(this.config.selectors.buttons.loop), { type: 'button', class: this.config.classNames.control, 'data-plyr-loop-action': option, }), i18n.get(option, this.config) ); if (['start', 'end'].includes(option)) { const badge = controls.createBadge.call(this, '00:00'); button.appendChild(badge); } item.appendChild(button); list.appendChild(item); }); }, */ // Get current selected caption language // TODO: rework this to user the getter in the API? // Set a list of available captions languages setCaptionsMenu() { // Menu required if (!is.element(this.elements.settings.panels.captions)) { return; } // TODO: Captions or language? Currently it's mixed const type = 'captions'; const list = this.elements.settings.panels.captions.querySelector('[role="menu"]'); const tracks = captions.getTracks.call(this); const toggle = Boolean(tracks.length); // Toggle the pane and tab controls.toggleMenuButton.call(this, type, toggle); // Empty the menu emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); // If there's no captions, bail if (!toggle) { return; } // Generate options data const options = tracks.map((track, value) => ({ value, checked: this.captions.toggled && this.currentTrack === value, title: captions.getLabel.call(this, track), badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()), list, type: 'language' })); // Add the "Disabled" option to turn off captions options.unshift({ value: -1, checked: !this.captions.toggled, title: i18n.get('disabled', this.config), list, type: 'language' }); // Generate options options.forEach(controls.createMenuItem.bind(this)); controls.updateSetting.call(this, type, list); }, // Set a list of available captions languages setSpeedMenu() { // Menu required if (!is.element(this.elements.settings.panels.speed)) { return; } const type = 'speed'; const list = this.elements.settings.panels.speed.querySelector('[role="menu"]'); // Filter out invalid speeds this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed); // Toggle the pane and tab const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1; controls.toggleMenuButton.call(this, type, toggle); // Empty the menu emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); // If we're hiding, nothing more to do if (!toggle) { return; } // Create items this.options.speed.forEach(speed => { controls.createMenuItem.call(this, { value: speed, list, type, title: controls.getLabel.call(this, 'speed', speed) }); }); controls.updateSetting.call(this, type, list); }, // Check if we need to hide/show the settings menu checkMenu() { const { buttons } = this.elements.settings; const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden); toggleHidden(this.elements.settings.menu, !visible); }, // Focus the first menu item in a given (or visible) menu focusFirstMenuItem(pane, focusVisible = false) { if (this.elements.settings.popup.hidden) { return; } let target = pane; if (!is.element(target)) { target = Object.values(this.elements.settings.panels).find(p => !p.hidden); } const firstItem = target.querySelector('[role^="menuitem"]'); setFocus.call(this, firstItem, focusVisible); }, // Show/hide menu toggleMenu(input) { const { popup } = this.elements.settings; const button = this.elements.buttons.settings; // Menu and button are required if (!is.element(popup) || !is.element(button)) { return; } // True toggle by default const { hidden } = popup; let show = hidden; if (is.boolean(input)) { show = input; } else if (is.keyboardEvent(input) && input.key === 'Escape') { show = false; } else if (is.event(input)) { // If Plyr is in a shadowDOM, the event target is set to the component, instead of the // Element in the shadowDOM. The path, if available, is complete. const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target; const isMenuItem = popup.contains(target); // If the click was inside the menu or if the click // wasn't the button or menu item and we're trying to // show the menu (a doc click shouldn't show the menu) if (isMenuItem || !isMenuItem && input.target !== button && show) { return; } } // Set button attributes button.setAttribute('aria-expanded', show); // Show the actual popup toggleHidden(popup, !show); // Add class hook toggleClass(this.elements.container, this.config.classNames.menu.open, show); // Focus the first item if key interaction if (show && is.keyboardEvent(input)) { controls.focusFirstMenuItem.call(this, null, true); } else if (!show && !hidden) { // If closing, re-focus the button setFocus.call(this, button, is.keyboardEvent(input)); } }, // Get the natural size of a menu panel getMenuSize(tab) { const clone = tab.cloneNode(true); clone.style.position = 'absolute'; clone.style.opacity = 0; clone.removeAttribute('hidden'); // Append to parent so we get the "real" size tab.parentNode.appendChild(clone); // Get the sizes before we remove const width = clone.scrollWidth; const height = clone.scrollHeight; // Remove from the DOM removeElement(clone); return { width, height }; }, // Show a panel in the menu showMenuPanel(type = '', focusVisible = false) { const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`); // Nothing to show, bail if (!is.element(target)) { return; } // Hide all other panels const container = target.parentNode; const current = Array.from(container.children).find(node => !node.hidden); // If we can do fancy animations, we'll animate the height/width if (support.transitions && !support.reducedMotion) { // Set the current width as a base container.style.width = `${current.scrollWidth}px`; container.style.height = `${current.scrollHeight}px`; // Get potential sizes const size = controls.getMenuSize.call(this, target); // Restore auto height/width const restore = event => { // We're only bothered about height and width on the container if (event.target !== container || !['width', 'height'].includes(event.propertyName)) { return; } // Revert back to auto container.style.width = ''; container.style.height = ''; // Only listen once off.call(this, container, transitionEndEvent, restore); }; // Listen for the transition finishing and restore auto height/width on.call(this, container, transitionEndEvent, restore); // Set dimensions to target container.style.width = `${size.width}px`; container.style.height = `${size.height}px`; } // Set attributes on current tab toggleHidden(current, true); // Set attributes on target toggleHidden(target, false); // Focus the first item controls.focusFirstMenuItem.call(this, target, focusVisible); }, // Set the download URL setDownloadUrl() { const button = this.elements.buttons.download; // Bail if no button if (!is.element(button)) { return; } // Set attribute button.setAttribute('href', this.download); }, // Build the default HTML create(data) { const { bindMenuItemShortcuts, createButton, createProgress, createRange, createTime, setQualityMenu, setSpeedMenu, showMenuPanel } = controls; this.elements.controls = null; // Larger overlaid play button if (is.array(this.config.controls) && this.config.controls.includes('play-large')) { this.elements.container.appendChild(createButton.call(this, 'play-large')); } // Create the container const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); this.elements.controls = container; // Default item attributes const defaultAttributes = { class: 'plyr__controls__item' }; // Loop through controls in order dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach(control => { // Restart button if (control === 'restart') { container.appendChild(createButton.call(this, 'restart', defaultAttributes)); } // Rewind button if (control === 'rewind') { container.appendChild(createButton.call(this, 'rewind', defaultAttributes)); } // Play/Pause button if (control === 'play') { container.appendChild(createButton.call(this, 'play', defaultAttributes)); } // Fast forward button if (control === 'fast-forward') { container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes)); } // Progress if (control === 'progress') { const progressContainer = createElement('div', { class: `${defaultAttributes.class} plyr__progress__container` }); const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); // Seek range slider progress.appendChild(createRange.call(this, 'seek', { id: `plyr-seek-${data.id}` })); // Buffer progress progress.appendChild(createProgress.call(this, 'buffer')); // TODO: Add loop display indicator // Seek tooltip if (this.config.tooltips.seek) { const tooltip = createElement('span', { class: this.config.classNames.tooltip }, '00:00'); progress.appendChild(tooltip); this.elements.display.seekTooltip = tooltip; } this.elements.progress = progress; progressContainer.appendChild(this.elements.progress); container.appendChild(progressContainer); } // Media current time display if (control === 'current-time') { container.appendChild(createTime.call(this, 'currentTime', defaultAttributes)); } // Media duration display if (control === 'duration') { container.appendChild(createTime.call(this, 'duration', defaultAttributes)); } // Volume controls if (control === 'mute' || control === 'volume') { let { volume } = this.elements; // Create the volume container if needed if (!is.element(volume) || !container.contains(volume)) { volume = createElement('div', extend({}, defaultAttributes, { class: `${defaultAttributes.class} plyr__volume`.trim() })); this.elements.volume = volume; container.appendChild(volume); } // Toggle mute button if (control === 'mute') { volume.appendChild(createButton.call(this, 'mute')); } // Volume range control // Ignored on iOS as it's handled globally // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html if (control === 'volume' && !browser.isIos && !browser.isIPadOS) { // Set the attributes const attributes = { max: 1, step: 0.05, value: this.config.volume }; // Create the volume range slider volume.appendChild(createRange.call(this, 'volume', extend(attributes, { id: `plyr-volume-${data.id}` }))); } } // Toggle captions button if (control === 'captions') { container.appendChild(createButton.call(this, 'captions', defaultAttributes)); } // Settings button / menu if (control === 'settings' && !is.empty(this.config.settings)) { const wrapper = createElement('div', extend({}, defaultAttributes, { class: `${defaultAttributes.class} plyr__menu`.trim(), hidden: '' })); wrapper.appendChild(createButton.call(this, 'settings', { 'aria-haspopup': true, 'aria-controls': `plyr-settings-${data.id}`, 'aria-expanded': false })); const popup = createElement('div', { class: 'plyr__menu__container', id: `plyr-settings-${data.id}`, hidden: '' }); const inner = createElement('div'); const home = createElement('div', { id: `plyr-settings-${data.id}-home` }); // Create the menu const menu = createElement('div', { role: 'menu' }); home.appendChild(menu); inner.appendChild(home); this.elements.settings.panels.home = home; // Build the menu items this.config.settings.forEach(type => { // TODO: bundle this with the createMenuItem helper and bindings const menuItem = createElement('button', extend(getAttributesFromSelector(this.config.selectors.buttons.settings), { type: 'button', class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`, role: 'menuitem', 'aria-haspopup': true, hidden: '' })); // Bind menu shortcuts for keyboard users bindMenuItemShortcuts.call(this, menuItem, type); // Show menu on click on.call(this, menuItem, 'click', () => { showMenuPanel.call(this, type, false); }); const flex = createElement('span', null, i18n.get(type, this.config)); const value = createElement('span', { class: this.config.classNames.menu.value }); // Speed contains HTML entities value.innerHTML = data[type]; flex.appendChild(value); menuItem.appendChild(flex); menu.appendChild(menuItem); // Build the panes const pane = createElement('div', { id: `plyr-settings-${data.id}-${type}`, hidden: '' }); // Back button const backButton = createElement('button', { type: 'button', class: `${this.config.classNames.control} ${this.config.classNames.control}--back` }); // Visible label backButton.appendChild(createElement('span', { 'aria-hidden': true }, i18n.get(type, this.config))); // Screen reader label backButton.appendChild(createElement('span', { class: this.config.classNames.hidden }, i18n.get('menuBack', this.config))); // Go back via keyboard on.call(this, pane, 'keydown', event => { if (event.key !== 'ArrowLeft') return; // Prevent seek event.preventDefault(); event.stopPropagation(); // Show the respective menu showMenuPanel.call(this, 'home', true); }, false); // Go back via button click on.call(this, backButton, 'click', () => { showMenuPanel.call(this, 'home', false); }); // Add to pane pane.appendChild(backButton); // Menu pane.appendChild(createElement('div', { role: 'menu' })); inner.appendChild(pane); this.elements.settings.buttons[type] = menuItem; this.elements.settings.panels[type] = pane; }); popup.appendChild(inner); wrapper.appendChild(popup); container.appendChild(wrapper); this.elements.settings.popup = popup; this.elements.settings.menu = wrapper; } // Picture in picture button if (control === 'pip' && support.pip) { container.appendChild(createButton.call(this, 'pip', defaultAttributes)); } // Airplay button if (control === 'airplay' && support.airplay) { container.appendChild(createButton.call(this, 'airplay', defaultAttributes)); } // Download button if (control === 'download') { const attributes = extend({}, defaultAttributes, { element: 'a', href: this.download, target: '_blank' }); // Set download attribute for HTML5 only if (this.isHTML5) { attributes.download = ''; } const { download } = this.config.urls; if (!is.url(download) && this.isEmbed) { extend(attributes, { icon: `logo-${this.provider}`, label: this.provider }); } container.appendChild(createButton.call(this, 'download', attributes)); } // Toggle fullscreen button if (control === 'fullscreen') { container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes)); } }); // Set available quality levels if (this.isHTML5) { setQualityMenu.call(this, html5.getQualityOptions.call(this)); } setSpeedMenu.call(this); return container; }, // Insert controls inject() { // Sprite if (this.config.loadSprite) { const icon = controls.getIconUrl.call(this); // Only load external sprite using AJAX if (icon.cors) { loadSprite(icon.url, 'sprite-plyr'); } } // Create a unique ID this.id = Math.floor(Math.random() * 10000); // Null by default let container = null; this.elements.controls = null; // Set template properties const props = { id: this.id, seektime: this.config.seekTime, title: this.config.title }; let update = true; // If function, run it and use output if (is.function(this.config.controls)) { this.config.controls = this.config.controls.call(this, props); } // Convert falsy controls to empty array (primarily for empty strings) if (!this.config.controls) { this.config.controls = []; } if (is.element(this.config.controls) || is.string(this.config.controls)) { // HTMLElement or Non-empty string passed as the option container = this.config.controls; } else { // Create controls container = controls.create.call(this, { id: this.id, seektime: this.config.seekTime, speed: this.speed, quality: this.quality, captions: captions.getLabel.call(this) // TODO: Looping // loop: 'None', }); update = false; } // Replace props with their value const replace = input => { let result = input; Object.entries(props).forEach(([key, value]) => { result = replaceAll(result, `{${key}}`, value); }); return result; }; // Update markup if (update) { if (is.string(this.config.controls)) { container = replace(container); } } // Controls container let target; // Inject to custom location if (is.string(this.config.selectors.controls.container)) { target = document.querySelector(this.config.selectors.controls.container); } // Inject into the container by default if (!is.element(target)) { target = this.elements.container; } // Inject controls HTML (needs to be before captions, hence "afterbegin") const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML'; target[insertMethod]('afterbegin', container); // Find the elements if need be if (!is.element(this.elements.controls)) { controls.findElements.call(this); } // Add pressed property to buttons if (!is.empty(this.elements.buttons)) { const addProperty = button => { const className = this.config.classNames.controlPressed; button.setAttribute('aria-pressed', 'false'); Object.defineProperty(button, 'pressed', { configurable: true, enumerable: true, get() { return hasClass(button, className); }, set(pressed = false) { toggleClass(button, className, pressed); button.setAttribute('aria-pressed', pressed ? 'true' : 'false'); } }); }; // Toggle classname when pressed property is set Object.values(this.elements.buttons).filter(Boolean).forEach(button => { if (is.array(button) || is.nodeList(button)) { Array.from(button).filter(Boolean).forEach(addProperty); } else { addProperty(button); } }); } // Edge sometimes doesn't finish the paint so force a repaint if (browser.isEdge) { repaint(target); } // Setup tooltips if (this.config.tooltips.controls) { const { classNames, selectors } = this.config; const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`; const labels = getElements.call(this, selector); Array.from(labels).forEach(label => { toggleClass(label, this.config.classNames.hidden, false); toggleClass(label, this.config.classNames.tooltip, true); }); } }, // Set media metadata setMediaMetadata() { try { if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new window.MediaMetadata({ title: this.config.mediaMetadata.title, artist: this.config.mediaMetadata.artist, album: this.config.mediaMetadata.album, artwork: this.config.mediaMetadata.artwork }); } } catch (_) { // Do nothing } }, // Add markers setMarkers() { var _this$config$markers2, _this$config$markers3; if (!this.duration || this.elements.markers) return; // Get valid points 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(({ time }) => time > 0 && time < this.duration); if (!(points !== null && points !== void 0 && points.length)) return; const containerFragment = document.createDocumentFragment(); const pointsFragment = document.createDocumentFragment(); let tipElement = null; const tipVisible = `${this.config.classNames.tooltip}--visible`; const toggleTip = show => toggleClass(tipElement, tipVisible, show); // Inject markers to progress container points.forEach(point => { const markerElement = createElement('span', { class: this.config.classNames.marker }, ''); const left = `${point.time / this.duration * 100}%`; if (tipElement) { // Show on hover markerElement.addEventListener('mouseenter', () => { if (point.label) return; tipElement.style.left = left; tipElement.innerHTML = point.label; toggleTip(true); }); // Hide on leave markerElement.addEventListener('mouseleave', () => { toggleTip(false); }); } markerElement.addEventListener('click', () => { this.currentTime = point.time; }); markerElement.style.left = left; pointsFragment.appendChild(markerElement); }); containerFragment.appendChild(pointsFragment); // Inject a tooltip if needed if (!this.config.tooltips.seek) { tipElement = createElement('span', { class: this.config.classNames.tooltip }, ''); containerFragment.appendChild(tipElement); } this.elements.markers = { points: pointsFragment, tip: tipElement }; this.elements.progress.appendChild(containerFragment); } }; // ========================================================================== // URL utils // ========================================================================== /** * Parse a string to a URL object * @param {String} input - the URL to be parsed * @param {Boolean} safe - failsafe parsing */ function parseUrl(input, safe = true) { let url = input; if (safe) { const parser = document.createElement('a'); parser.href = url; url = parser.href; } try { return new URL(url); } catch (_) { return null; } } // Convert object to URLSearchParams function buildUrlParams(input) { const params = new URLSearchParams(); if (is.object(input)) { Object.entries(input).forEach(([key, value]) => { params.set(key, value); }); } return params; } // ========================================================================== // Plyr Captions // TODO: Create as class // ========================================================================== const captions = { // Setup captions setup() { // Requires UI support if (!this.supported.ui) { return; } // Only Vimeo and HTML5 video supported at this point if (!this.isVideo || this.isYouTube || this.isHTML5 && !support.textTracks) { // Clear menu and hide if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) { controls.setCaptionsMenu.call(this); } return; } // Inject the container if (!is.element(this.elements.captions)) { this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions)); this.elements.captions.setAttribute('dir', 'auto'); insertAfter(this.elements.captions, this.elements.wrapper); } // Fix IE captions if CORS is used // Fetch captions and inject as blobs instead (data URIs not supported!) if (browser.isIE && window.URL) { const elements = this.media.querySelectorAll('track'); Array.from(elements).forEach(track => { const src = track.getAttribute('src'); const url = parseUrl(src); if (url !== null && url.hostname !== window.location.href.hostname && ['http:', 'https:'].includes(url.protocol)) { fetch(src, 'blob').then(blob => { track.setAttribute('src', window.URL.createObjectURL(blob)); }).catch(() => { removeElement(track); }); } }); } // Get and set initial data // The "preferred" options are not realized unless / until the wanted language has a match // * languages: Array of user's browser languages. // * language: The language preferred by user settings or config // * active: The state preferred by user settings or config // * toggled: The real captions state const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en']; const languages = dedupe(browserLanguages.map(language => language.split('-')[0])); let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase(); // Use first browser language when language is 'auto' if (language === 'auto') { [language] = languages; } let active = this.storage.get('captions'); if (!is.boolean(active)) { ({ active } = this.config.captions); } Object.assign(this.captions, { toggled: false, active, language, languages }); // Watch changes to textTracks and update captions menu if (this.isHTML5) { const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack'; on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this)); } // Update available languages in list next tick (the event must not be triggered before the listeners) setTimeout(captions.update.bind(this), 0); }, // Update available language options in settings based on tracks update() { const tracks = captions.getTracks.call(this, true); // Get the wanted language const { active, language, meta, currentTrackNode } = this.captions; const languageExists = Boolean(tracks.find(track => track.language === language)); // Handle tracks (add event listener and "pseudo"-default) if (this.isHTML5 && this.isVideo) { tracks.filter(track => !meta.get(track)).forEach(track => { this.debug.log('Track added', track); // Attempt to store if the original dom element was "default" meta.set(track, { default: track.mode === 'showing' }); // Turn off native caption rendering to avoid double captions // Note: mode='hidden' forces a track to download. To ensure every track // isn't downloaded at once, only 'showing' tracks should be reassigned // eslint-disable-next-line no-param-reassign if (track.mode === 'showing') { // eslint-disable-next-line no-param-reassign track.mode = 'hidden'; } // Add event listener for cue changes on.call(this, track, 'cuechange', () => captions.updateCues.call(this)); }); } // Update language first time it matches, or if the previous matching track was removed if (languageExists && this.language !== language || !tracks.includes(currentTrackNode)) { captions.setLanguage.call(this, language); captions.toggle.call(this, active && languageExists); } // Enable or disable captions based on track length if (this.elements) { toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks)); } // Update available languages in list if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) { controls.setCaptionsMenu.call(this); } }, // Toggle captions display // Used internally for the toggleCaptions method, with the passive option forced to false toggle(input, passive = true) { // If there's no full support if (!this.supported.ui) { return; } const { toggled } = this.captions; // Current state const activeClass = this.config.classNames.captions.active; // Get the next state // If the method is called without parameter, toggle based on current value const active = is.nullOrUndefined(input) ? !toggled : input; // Update state and trigger event if (active !== toggled) { // When passive, don't override user preferences if (!passive) { this.captions.active = active; this.storage.set({ captions: active }); } // Force language if the call isn't passive and there is no matching language to toggle to if (!this.language && active && !passive) { const tracks = captions.getTracks.call(this); 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 this.captions.language = track.language; // Set caption, but don't store in localStorage as user preference captions.set.call(this, tracks.indexOf(track)); return; } // Toggle button if it's enabled if (this.elements.buttons.captions) { this.elements.buttons.captions.pressed = active; } // Add class hook toggleClass(this.elements.container, activeClass, active); this.captions.toggled = active; // Update settings menu controls.updateSetting.call(this, 'captions'); // Trigger event (not used internally) triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled'); } // Wait for the call stack to clear before setting mode='hidden' // on the active track - forcing the browser to download it setTimeout(() => { if (active && this.captions.toggled) { this.captions.currentTrackNode.mode = 'hidden'; } }); }, // Set captions by track index // Used internally for the currentTrack setter with the passive option forced to false set(index, passive = true) { const tracks = captions.getTracks.call(this); // Disable captions if setting to -1 if (index === -1) { captions.toggle.call(this, false, passive); return; } if (!is.number(index)) { this.debug.warn('Invalid caption argument', index); return; } if (!(index in tracks)) { this.debug.warn('Track not found', index); return; } if (this.captions.currentTrack !== index) { this.captions.currentTrack = index; const track = tracks[index]; const { language } = track || {}; // Store reference to node for invalidation on remove this.captions.currentTrackNode = track; // Update settings menu controls.updateSetting.call(this, 'captions'); // When passive, don't override user preferences if (!passive) { this.captions.language = language; this.storage.set({ language }); } // Handle Vimeo captions if (this.isVimeo) { this.embed.enableTextTrack(language); } // Trigger event triggerEvent.call(this, this.media, 'languagechange'); } // Show captions captions.toggle.call(this, true, passive); if (this.isHTML5 && this.isVideo) { // If we change the active track while a cue is already displayed we need to update it captions.updateCues.call(this); } }, // Set captions by language // Used internally for the language setter with the passive option forced to false setLanguage(input, passive = true) { if (!is.string(input)) { this.debug.warn('Invalid language argument', input); return; } // Normalize const language = input.toLowerCase(); this.captions.language = language; // Set currentTrack const tracks = captions.getTracks.call(this); const track = captions.findTrack.call(this, [language]); captions.set.call(this, tracks.indexOf(track), passive); }, // Get current valid caption tracks // If update is false it will also ignore tracks without metadata // This is used to "freeze" the language options when captions.update is false getTracks(update = false) { // Handle media or textTracks missing or null const tracks = Array.from((this.media || {}).textTracks || []); // For HTML5, use cache instead of current tracks when it exists (if captions.update is false) // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata) return tracks.filter(track => !this.isHTML5 || update || this.captions.meta.has(track)).filter(track => ['captions', 'subtitles'].includes(track.kind)); }, // Match tracks based on languages and get the first findTrack(languages, force = false) { const tracks = captions.getTracks.call(this); const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default); const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a)); let track; languages.every(language => { track = sorted.find(t => t.language === language); return !track; // Break iteration if there is a match }); // If no match is found but is required, get first return track || (force ? sorted[0] : undefined); }, // Get the current track getCurrentTrack() { return captions.getTracks.call(this)[this.currentTrack]; }, // Get UI label for track getLabel(track) { let currentTrack = track; if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) { currentTrack = captions.getCurrentTrack.call(this); } if (is.track(currentTrack)) { if (!is.empty(currentTrack.label)) { return currentTrack.label; } if (!is.empty(currentTrack.language)) { return track.language.toUpperCase(); } return i18n.get('enabled', this.config); } return i18n.get('disabled', this.config); }, // Update captions using current track's active cues // Also optional array argument in case there isn't any track (ex: vimeo) updateCues(input) { // Requires UI if (!this.supported.ui) { return; } if (!is.element(this.elements.captions)) { this.debug.warn('No captions element to render to'); return; } // Only accept array or empty input if (!is.nullOrUndefined(input) && !Array.isArray(input)) { this.debug.warn('updateCues: Invalid input', input); return; } let cues = input; // Get cues from track if (!cues) { const track = captions.getCurrentTrack.call(this); cues = Array.from((track || {}).activeCues || []).map(cue => cue.getCueAsHTML()).map(getHTML); } // Set new caption text const content = cues.map(cueText => cueText.trim()).join('\n'); const changed = content !== this.elements.captions.innerHTML; if (changed) { // Empty the container and create a new child element emptyElement(this.elements.captions); const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption)); caption.innerHTML = content; this.elements.captions.appendChild(caption); // Trigger event triggerEvent.call(this, this.media, 'cuechange'); } } }; // ========================================================================== // Plyr default config // ========================================================================== const defaults = { // Disable enabled: true, // Custom media title title: '', // Logging to console debug: false, // Auto play (if supported) autoplay: false, // Only allow one media playing at once (vimeo only) autopause: true, // Allow inline playback on iOS playsinline: true, // Default time to skip when rewind/fast forward seekTime: 10, // Default volume volume: 1, muted: false, // Pass a custom duration duration: null, // Display the media duration on load in the current time position // If you have opted to display both duration and currentTime, this is ignored displayDuration: true, // Invert the current time to be a countdown invertTime: true, // Clicking the currentTime inverts it's value to show time left rather than elapsed toggleInvert: true, // Force an aspect ratio // The format must be `'w:h'` (e.g. `'16:9'`) ratio: null, // Click video container to play/pause clickToPlay: true, // Auto hide the controls hideControls: true, // Reset to start when playback ended resetOnEnd: false, // Disable the standard context menu disableContextMenu: true, // Sprite (for icons) loadSprite: true, iconPrefix: 'plyr', iconUrl: 'https://cdn.plyr.io/3.7.8/plyr.svg', // Blank video (used to prevent errors on source change) blankVideo: 'https://cdn.plyr.io/static/blank.mp4', // Quality default quality: { default: 576, // The options to display in the UI, if available for the source media options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240], forced: false, onChange: null }, // Set loops loop: { active: false // start: null, // end: null, }, // Speed default and options to display speed: { selected: 1, // The options to display in the UI, if available for the source media (e.g. Vimeo and YouTube only support 0.5x-4x) options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4] }, // Keyboard shortcut settings keyboard: { focused: true, global: false }, // Display tooltips tooltips: { controls: false, seek: true }, // Captions settings captions: { active: false, language: 'auto', // Listen to new tracks added after Plyr is initialized. // This is needed for streaming captions, but may result in unselectable options update: false }, // Fullscreen settings fullscreen: { enabled: true, // Allow fullscreen? fallback: true, // Fallback using full viewport/window iosNative: false // Use the native fullscreen in iOS (disables custom controls) // Selector for the fullscreen container so contextual / non-player content can remain visible in fullscreen mode // Non-ancestors of the player element will be ignored // container: null, // defaults to the player element }, // Local storage storage: { enabled: true, key: 'plyr' }, // Default controls controls: ['play-large', // 'restart', // 'rewind', 'play', // 'fast-forward', 'progress', 'current-time', // 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', // 'download', 'fullscreen'], settings: ['captions', 'quality', 'speed'], // Localisation i18n: { restart: 'Restart', rewind: 'Rewind {seektime}s', play: 'Play', pause: 'Pause', fastForward: 'Forward {seektime}s', seek: 'Seek', seekLabel: '{currentTime} of {duration}', played: 'Played', buffered: 'Buffered', currentTime: 'Current time', duration: 'Duration', volume: 'Volume', mute: 'Mute', unmute: 'Unmute', enableCaptions: 'Enable captions', disableCaptions: 'Disable captions', download: 'Download', enterFullscreen: 'Enter fullscreen', exitFullscreen: 'Exit fullscreen', frameTitle: 'Player for {title}', captions: 'Captions', settings: 'Settings', pip: 'PIP', menuBack: 'Go back to previous menu', speed: 'Speed', normal: 'Normal', quality: 'Quality', loop: 'Loop', start: 'Start', end: 'End', all: 'All', reset: 'Reset', disabled: 'Disabled', enabled: 'Enabled', advertisement: 'Ad', qualityBadge: { 2160: '4K', 1440: 'HD', 1080: 'HD', 720: 'HD', 576: 'SD', 480: 'SD' } }, // URLs urls: { download: null, vimeo: { sdk: 'https://player.vimeo.com/api/player.js', iframe: 'https://player.vimeo.com/video/{0}?{1}', api: 'https://vimeo.com/api/oembed.json?url={0}' }, youtube: { sdk: 'https://www.youtube.com/iframe_api', api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}' }, googleIMA: { sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js' } }, // Custom control listeners listeners: { seek: null, play: null, pause: null, restart: null, rewind: null, fastForward: null, mute: null, volume: null, captions: null, download: null, fullscreen: null, pip: null, airplay: null, speed: null, quality: null, loop: null, language: null }, // Events to watch and bubble events: [ // Events to watch on HTML5 media elements and bubble // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events 'ended', 'progress', 'stalled', 'playing', 'waiting', 'canplay', 'canplaythrough', 'loadstart', 'loadeddata', 'loadedmetadata', 'timeupdate', 'volumechange', 'play', 'pause', 'error', 'seeking', 'seeked', 'emptied', 'ratechange', 'cuechange', // Custom events 'download', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', 'captionsdisabled', 'languagechange', 'controlshidden', 'controlsshown', 'ready', // YouTube 'statechange', // Quality 'qualitychange', // Ads 'adsloaded', 'adscontentpause', 'adscontentresume', 'adstarted', 'adsmidpoint', 'adscomplete', 'adsallcomplete', 'adsimpression', 'adsclick'], // Selectors // Change these to match your template if using custom HTML selectors: { editable: 'input, textarea, select, [contenteditable]', container: '.plyr', controls: { container: null, wrapper: '.plyr__controls' }, labels: '[data-plyr]', buttons: { play: '[data-plyr="play"]', pause: '[data-plyr="pause"]', restart: '[data-plyr="restart"]', rewind: '[data-plyr="rewind"]', fastForward: '[data-plyr="fast-forward"]', mute: '[data-plyr="mute"]', captions: '[data-plyr="captions"]', download: '[data-plyr="download"]', fullscreen: '[data-plyr="fullscreen"]', pip: '[data-plyr="pip"]', airplay: '[data-plyr="airplay"]', settings: '[data-plyr="settings"]', loop: '[data-plyr="loop"]' }, inputs: { seek: '[data-plyr="seek"]', volume: '[data-plyr="volume"]', speed: '[data-plyr="speed"]', language: '[data-plyr="language"]', quality: '[data-plyr="quality"]' }, display: { currentTime: '.plyr__time--current', duration: '.plyr__time--duration', buffer: '.plyr__progress__buffer', loop: '.plyr__progress__loop', // Used later volume: '.plyr__volume--display' }, progress: '.plyr__progress', captions: '.plyr__captions', caption: '.plyr__caption' }, // Class hooks added to the player in different states classNames: { type: 'plyr--{0}', provider: 'plyr--{0}', video: 'plyr__video-wrapper', embed: 'plyr__video-embed', videoFixedRatio: 'plyr__video-wrapper--fixed-ratio', embedContainer: 'plyr__video-embed__container', poster: 'plyr__poster', posterEnabled: 'plyr__poster-enabled', ads: 'plyr__ads', control: 'plyr__control', controlPressed: 'plyr__control--pressed', playing: 'plyr--playing', paused: 'plyr--paused', stopped: 'plyr--stopped', loading: 'plyr--loading', hover: 'plyr--hover', tooltip: 'plyr__tooltip', cues: 'plyr__cues', marker: 'plyr__progress__marker', hidden: 'plyr__sr-only', hideControls: 'plyr--hide-controls', isTouch: 'plyr--is-touch', uiSupported: 'plyr--full-ui', noTransition: 'plyr--no-transition', display: { time: 'plyr__time' }, menu: { value: 'plyr__menu__value', badge: 'plyr__badge', open: 'plyr--menu-open' }, captions: { enabled: 'plyr--captions-enabled', active: 'plyr--captions-active' }, fullscreen: { enabled: 'plyr--fullscreen-enabled', fallback: 'plyr--fullscreen-fallback' }, pip: { supported: 'plyr--pip-supported', active: 'plyr--pip-active' }, airplay: { supported: 'plyr--airplay-supported', active: 'plyr--airplay-active' }, previewThumbnails: { // Tooltip thumbs thumbContainer: 'plyr__preview-thumb', thumbContainerShown: 'plyr__preview-thumb--is-shown', imageContainer: 'plyr__preview-thumb__image-container', timeContainer: 'plyr__preview-thumb__time-container', // Scrubbing scrubbingContainer: 'plyr__preview-scrubbing', scrubbingContainerShown: 'plyr__preview-scrubbing--is-shown' } }, // Embed attributes attributes: { embed: { provider: 'data-plyr-provider', id: 'data-plyr-embed-id', hash: 'data-plyr-embed-hash' } }, // Advertisements plugin // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio ads: { enabled: false, publisherId: '', tagUrl: '' }, // Preview Thumbnails plugin previewThumbnails: { enabled: false, src: '' }, // Vimeo plugin vimeo: { byline: false, portrait: false, title: false, speed: true, transparent: false, // Custom settings from Plyr customControls: true, referrerPolicy: null, // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy // Whether the owner of the video has a Pro or Business account // (which allows us to properly hide controls without CSS hacks, etc) premium: false }, // YouTube plugin youtube: { rel: 0, // No related vids showinfo: 0, // Hide info iv_load_policy: 3, // Hide annotations modestbranding: 1, // Hide logos as much as possible (they still show one in the corner when paused) // Custom settings from Plyr customControls: true, noCookie: false // Whether to use an alternative version of YouTube without cookies }, // Media Metadata mediaMetadata: { title: '', artist: '', album: '', artwork: [] }, // Markers markers: { enabled: false, points: [] } }; // ========================================================================== // Plyr states // ========================================================================== const pip = { active: 'picture-in-picture', inactive: 'inline' }; // ========================================================================== // Plyr supported types and providers // ========================================================================== const providers = { html5: 'html5', youtube: 'youtube', vimeo: 'vimeo' }; const types = { audio: 'audio', video: 'video' }; /** * Get provider by URL * @param {String} url */ function getProviderByUrl(url) { // YouTube if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(url)) { return providers.youtube; } // Vimeo if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) { return providers.vimeo; } return null; } // ========================================================================== // Console wrapper // ========================================================================== const noop = () => {}; class Console { constructor(enabled = false) { this.enabled = window.console && enabled; if (this.enabled) { this.log('Debugging enabled'); } } get log() { // eslint-disable-next-line no-console return this.enabled ? Function.prototype.bind.call(console.log, console) : noop; } get warn() { // eslint-disable-next-line no-console return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop; } get error() { // eslint-disable-next-line no-console return this.enabled ? Function.prototype.bind.call(console.error, console) : noop; } } class Fullscreen { constructor(player) { _defineProperty$1(this, "onChange", () => { if (!this.supported) return; // Update toggle button const button = this.player.elements.buttons.fullscreen; if (is.element(button)) { button.pressed = this.active; } // Always trigger events on the plyr / media element (not a fullscreen container) and let them bubble up const target = this.target === this.player.media ? this.target : this.player.elements.container; // Trigger an event triggerEvent.call(this.player, target, this.active ? 'enterfullscreen' : 'exitfullscreen', true); }); _defineProperty$1(this, "toggleFallback", (toggle = false) => { // Store or restore scroll position if (toggle) { this.scrollPosition = { x: window.scrollX ?? 0, y: window.scrollY ?? 0 }; } else { window.scrollTo(this.scrollPosition.x, this.scrollPosition.y); } // Toggle scroll document.body.style.overflow = toggle ? 'hidden' : ''; // Toggle class hook toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle); // Force full viewport on iPhone X+ if (browser.isIos) { let viewport = document.head.querySelector('meta[name="viewport"]'); const property = 'viewport-fit=cover'; // Inject the viewport meta if required if (!viewport) { viewport = document.createElement('meta'); viewport.setAttribute('name', 'viewport'); } // Check if the property already exists const hasProperty = is.string(viewport.content) && viewport.content.includes(property); if (toggle) { this.cleanupViewport = !hasProperty; if (!hasProperty) viewport.content += `,${property}`; } else if (this.cleanupViewport) { viewport.content = viewport.content.split(',').filter(part => part.trim() !== property).join(','); } } // Toggle button and fire events this.onChange(); }); // Trap focus inside container _defineProperty$1(this, "trapFocus", event => { // Bail if iOS/iPadOS, not active, not the tab key if (browser.isIos || browser.isIPadOS || !this.active || event.key !== 'Tab') return; // Get the current focused element const focused = document.activeElement; const focusable = getElements.call(this.player, 'a[href], button:not(:disabled), input:not(:disabled), [tabindex]'); const [first] = focusable; const last = focusable[focusable.length - 1]; if (focused === last && !event.shiftKey) { // Move focus to first element that can be tabbed if Shift isn't used first.focus(); event.preventDefault(); } else if (focused === first && event.shiftKey) { // Move focus to last element that can be tabbed if Shift is used last.focus(); event.preventDefault(); } }); // Update UI _defineProperty$1(this, "update", () => { if (this.supported) { let mode; if (this.forceFallback) mode = 'Fallback (forced)';else if (Fullscreen.nativeSupported) mode = 'Native';else mode = 'Fallback'; this.player.debug.log(`${mode} fullscreen enabled`); } else { this.player.debug.log('Fullscreen not supported and fallback disabled'); } // Add styling hook to show button toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.supported); }); // Make an element fullscreen _defineProperty$1(this, "enter", () => { if (!this.supported) return; // iOS native fullscreen doesn't need the request step if (browser.isIos && this.player.config.fullscreen.iosNative) { if (this.player.isVimeo) { this.player.embed.requestFullscreen(); } else { this.target.webkitEnterFullscreen(); } } else if (!Fullscreen.nativeSupported || this.forceFallback) { this.toggleFallback(true); } else if (!this.prefix) { this.target.requestFullscreen({ navigationUI: 'hide' }); } else if (!is.empty(this.prefix)) { this.target[`${this.prefix}Request${this.property}`](); } }); // Bail from fullscreen _defineProperty$1(this, "exit", () => { if (!this.supported) return; // iOS native fullscreen if (browser.isIos && this.player.config.fullscreen.iosNative) { if (this.player.isVimeo) { this.player.embed.exitFullscreen(); } else { this.target.webkitEnterFullscreen(); } silencePromise(this.player.play()); } else if (!Fullscreen.nativeSupported || this.forceFallback) { this.toggleFallback(false); } else if (!this.prefix) { (document.cancelFullScreen || document.exitFullscreen).call(document); } else if (!is.empty(this.prefix)) { const action = this.prefix === 'moz' ? 'Cancel' : 'Exit'; document[`${this.prefix}${action}${this.property}`](); } }); // Toggle state _defineProperty$1(this, "toggle", () => { if (!this.active) this.enter();else this.exit(); }); // Keep reference to parent this.player = player; // Get prefix this.prefix = Fullscreen.prefix; this.property = Fullscreen.property; // Scroll position this.scrollPosition = { x: 0, y: 0 }; // Force the use of 'full window/browser' rather than fullscreen this.forceFallback = player.config.fullscreen.fallback === 'force'; // Get the fullscreen element // Checks container is an ancestor, defaults to null this.player.elements.fullscreen = player.config.fullscreen.container && closest$1(this.player.elements.container, player.config.fullscreen.container); // Register event listeners // Handle event (incase user presses escape etc) on.call(this.player, document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => { // TODO: Filter for target?? this.onChange(); }); // Fullscreen toggle on double click on.call(this.player, this.player.elements.container, 'dblclick', event => { // Ignore double click in controls if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) { return; } this.player.listeners.proxy(event, this.toggle, 'fullscreen'); }); // Tap focus when in fullscreen on.call(this, this.player.elements.container, 'keydown', event => this.trapFocus(event)); // Update the UI this.update(); } // Determine if native supported static get nativeSupported() { return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled); } // If we're actually using native get useNative() { return Fullscreen.nativeSupported && !this.forceFallback; } // Get the prefix for handlers static get prefix() { // No prefix if (is.function(document.exitFullscreen)) return ''; // Check for fullscreen support by vendor prefix let value = ''; const prefixes = ['webkit', 'moz', 'ms']; prefixes.some(pre => { if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) { value = pre; return true; } return false; }); return value; } static get property() { return this.prefix === 'moz' ? 'FullScreen' : 'Fullscreen'; } // Determine if fullscreen is supported get supported() { return [ // Fullscreen is enabled in config this.player.config.fullscreen.enabled, // Must be a video this.player.isVideo, // Either native is supported or fallback enabled Fullscreen.nativeSupported || this.player.config.fullscreen.fallback, // YouTube has no way to trigger fullscreen, so on devices with no native support, playsinline // must be enabled and iosNative fullscreen must be disabled to offer the fullscreen fallback !this.player.isYouTube || Fullscreen.nativeSupported || !browser.isIos || this.player.config.playsinline && !this.player.config.fullscreen.iosNative].every(Boolean); } // Get active state get active() { if (!this.supported) return false; // Fallback using classname if (!Fullscreen.nativeSupported || this.forceFallback) { return hasClass(this.target, this.player.config.classNames.fullscreen.fallback); } const element = !this.prefix ? this.target.getRootNode().fullscreenElement : this.target.getRootNode()[`${this.prefix}${this.property}Element`]; return element && element.shadowRoot ? element === this.target.getRootNode().host : element === this.target; } // Get target element get target() { return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.fullscreen ?? this.player.elements.container; } } // ========================================================================== // Load image avoiding xhr/fetch CORS issues // Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded // By default it checks if it is at least 1px, but you can add a second argument to change this // ========================================================================== function loadImage(src, minWidth = 1) { return new Promise((resolve, reject) => { const image = new Image(); const handler = () => { delete image.onload; delete image.onerror; (image.naturalWidth >= minWidth ? resolve : reject)(image); }; Object.assign(image, { onload: handler, onerror: handler, src }); }); } // ========================================================================== // Plyr UI // ========================================================================== const ui = { addStyleHook() { toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true); toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui); }, // Toggle native HTML5 media controls toggleNativeControls(toggle = false) { if (toggle && this.isHTML5) { this.media.setAttribute('controls', ''); } else { this.media.removeAttribute('controls'); } }, // Setup the UI build() { // Re-attach media element listeners // TODO: Use event bubbling? this.listeners.media(); // Don't setup interface if no support if (!this.supported.ui) { this.debug.warn(`Basic support only for ${this.provider} ${this.type}`); // Restore native controls ui.toggleNativeControls.call(this, true); // Bail return; } // Inject custom controls if not present if (!is.element(this.elements.controls)) { // Inject custom controls controls.inject.call(this); // Re-attach control listeners this.listeners.controls(); } // Remove native controls ui.toggleNativeControls.call(this); // Setup captions for HTML5 if (this.isHTML5) { captions.setup.call(this); } // Reset volume this.volume = null; // Reset mute state this.muted = null; // Reset loop state this.loop = null; // Reset quality setting this.quality = null; // Reset speed this.speed = null; // Reset volume display controls.updateVolume.call(this); // Reset time display controls.timeUpdate.call(this); // Reset duration display controls.durationUpdate.call(this); // Update the UI ui.checkPlaying.call(this); // Check for picture-in-picture support toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo); // Check for airplay support toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5); // Add touch class toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch); // Ready for API calls this.ready = true; // Ready event at end of execution stack setTimeout(() => { triggerEvent.call(this, this.media, 'ready'); }, 0); // Set the title ui.setTitle.call(this); // Assure the poster image is set, if the property was added before the element was created if (this.poster) { ui.setPoster.call(this, this.poster, false).catch(() => {}); } // Manually set the duration if user has overridden it. // The event listeners for it doesn't get called if preload is disabled (#701) if (this.config.duration) { controls.durationUpdate.call(this); } // Media metadata if (this.config.mediaMetadata) { controls.setMediaMetadata.call(this); } }, // Setup aria attribute for play and iframe title setTitle() { // Find the current text let label = i18n.get('play', this.config); // If there's a media title set, use that for the label if (is.string(this.config.title) && !is.empty(this.config.title)) { label += `, ${this.config.title}`; } // If there's a play button, set label Array.from(this.elements.buttons.play || []).forEach(button => { button.setAttribute('aria-label', label); }); // Set iframe title // https://github.com/sampotts/plyr/issues/124 if (this.isEmbed) { const iframe = getElement.call(this, 'iframe'); if (!is.element(iframe)) { return; } // Default to media type const title = !is.empty(this.config.title) ? this.config.title : 'video'; const format = i18n.get('frameTitle', this.config); iframe.setAttribute('title', format.replace('{title}', title)); } }, // Toggle poster togglePoster(enable) { toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable); }, // Set the poster image (async) // Used internally for the poster setter, with the passive option forced to false setPoster(poster, passive = true) { // Don't override if call is passive if (passive && this.poster) { return Promise.reject(new Error('Poster already set')); } // Set property synchronously to respect the call order this.media.setAttribute('data-poster', poster); // Show the poster this.elements.poster.removeAttribute('hidden'); // Wait until ui is ready return ready.call(this) // Load image .then(() => loadImage(poster)).catch(error => { // Hide poster on error unless it's been set by another call if (poster === this.poster) { ui.togglePoster.call(this, false); } // Rethrow throw error; }).then(() => { // Prevent race conditions if (poster !== this.poster) { throw new Error('setPoster cancelled by later call to setPoster'); } }).then(() => { Object.assign(this.elements.poster.style, { backgroundImage: `url('${poster}')`, // Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube) backgroundSize: '' }); ui.togglePoster.call(this, true); return poster; }); }, // Check playing state checkPlaying(event) { // Class hooks toggleClass(this.elements.container, this.config.classNames.playing, this.playing); toggleClass(this.elements.container, this.config.classNames.paused, this.paused); toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped); // Set state Array.from(this.elements.buttons.play || []).forEach(target => { Object.assign(target, { pressed: this.playing }); target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config)); }); // Only update controls on non timeupdate events if (is.event(event) && event.type === 'timeupdate') { return; } // Toggle controls ui.toggleControls.call(this); }, // Check if media is loading checkLoading(event) { this.loading = ['stalled', 'waiting'].includes(event.type); // Clear timer clearTimeout(this.timers.loading); // Timer to prevent flicker when seeking this.timers.loading = setTimeout(() => { // Update progress bar loading class state toggleClass(this.elements.container, this.config.classNames.loading, this.loading); // Update controls visibility ui.toggleControls.call(this); }, this.loading ? 250 : 0); }, // Toggle controls based on state and `force` argument toggleControls(force) { const { controls: controlsElement } = this.elements; if (controlsElement && this.config.hideControls) { // 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.) const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now(); // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide this.toggleControls(Boolean(force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek)); } }, // Migrate any custom properties from the media to the parent migrateStyles() { // Loop through values (as they are the keys when the object is spread 🤔) Object.values({ ...this.media.style }) // We're only fussed about Plyr specific properties .filter(key => !is.empty(key) && is.string(key) && key.startsWith('--plyr')).forEach(key => { // Set on the container this.elements.container.style.setProperty(key, this.media.style.getPropertyValue(key)); // Clean up from media element this.media.style.removeProperty(key); }); // Remove attribute if empty if (is.empty(this.media.style)) { this.media.removeAttribute('style'); } } }; class Listeners { constructor(_player) { // Device is touch enabled _defineProperty$1(this, "firstTouch", () => { const { player } = this; const { elements } = player; player.touch = true; // Add touch class toggleClass(elements.container, player.config.classNames.isTouch, true); }); // Global window & document listeners _defineProperty$1(this, "global", (toggle = true) => { const { player } = this; // Keyboard shortcuts if (player.config.keyboard.global) { toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false); } // Click anywhere closes menu toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle); // Detect touch by events once.call(player, document.body, 'touchstart', this.firstTouch); }); // Container listeners _defineProperty$1(this, "container", () => { const { player } = this; const { config, elements, timers } = player; // Keyboard shortcuts if (!config.keyboard.global && config.keyboard.focused) { on.call(player, elements.container, 'keydown keyup', this.handleKey, false); } // Toggle controls on mouse events and entering fullscreen on.call(player, elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => { const { controls: controlsElement } = elements; // Remove button states for fullscreen if (controlsElement && event.type === 'enterfullscreen') { controlsElement.pressed = false; controlsElement.hover = false; } // Show, then hide after a timeout unless another control event occurs const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type); let delay = 0; if (show) { ui.toggleControls.call(player, true); // Use longer timeout for touch devices delay = player.touch ? 3000 : 2000; } // Clear timer clearTimeout(timers.controls); // Set new timer to prevent flicker when seeking timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay); }); // Set a gutter for Vimeo const setGutter = () => { if (!player.isVimeo || player.config.vimeo.premium) { return; } const target = elements.wrapper; const { active } = player.fullscreen; const [videoWidth, videoHeight] = getAspectRatio.call(player); const useNativeAspectRatio = supportsCSS(`aspect-ratio: ${videoWidth} / ${videoHeight}`); // If not active, remove styles if (!active) { if (useNativeAspectRatio) { target.style.width = null; target.style.height = null; } else { target.style.maxWidth = null; target.style.margin = null; } return; } // Determine which dimension will overflow and constrain view const [viewportWidth, viewportHeight] = getViewportSize(); const overflow = viewportWidth / viewportHeight > videoWidth / videoHeight; if (useNativeAspectRatio) { target.style.width = overflow ? 'auto' : '100%'; target.style.height = overflow ? '100%' : 'auto'; } else { target.style.maxWidth = overflow ? `${viewportHeight / videoHeight * videoWidth}px` : null; target.style.margin = overflow ? '0 auto' : null; } }; // Handle resizing const resized = () => { clearTimeout(timers.resized); timers.resized = setTimeout(setGutter, 50); }; on.call(player, elements.container, 'enterfullscreen exitfullscreen', event => { const { target } = player.fullscreen; // Ignore events not from target if (target !== elements.container) { return; } // If it's not an embed and no ratio specified if (!player.isEmbed && is.empty(player.config.ratio)) { return; } // Set Vimeo gutter setGutter(); // Watch for resizes const method = event.type === 'enterfullscreen' ? on : off; method.call(player, window, 'resize', resized); }); }); // Listen for media events _defineProperty$1(this, "media", () => { const { player } = this; const { elements } = player; // Time change on media on.call(player, player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(player, event)); // Display duration on.call(player, player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(player, event)); // Handle the media finishing on.call(player, player.media, 'ended', () => { // Show poster on end if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) { // Restart player.restart(); // Call pause otherwise IE11 will start playing the video again player.pause(); } }); // Check for buffer progress on.call(player, player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(player, event)); // Handle volume changes on.call(player, player.media, 'volumechange', event => controls.updateVolume.call(player, event)); // Handle play/pause on.call(player, player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(player, event)); // Loading state on.call(player, player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(player, event)); // Click video if (player.supported.ui && player.config.clickToPlay && !player.isAudio) { // Re-fetch the wrapper const wrapper = getElement.call(player, `.${player.config.classNames.video}`); // Bail if there's no wrapper (this should never happen) if (!is.element(wrapper)) { return; } // On click play, pause or restart on.call(player, elements.container, 'click', event => { const targets = [elements.container, wrapper]; // Ignore if click if not container or in video wrapper if (!targets.includes(event.target) && !wrapper.contains(event.target)) { return; } // Touch devices will just show controls (if hidden) if (player.touch && player.config.hideControls) { return; } if (player.ended) { this.proxy(event, player.restart, 'restart'); this.proxy(event, () => { silencePromise(player.play()); }, 'play'); } else { this.proxy(event, () => { silencePromise(player.togglePlay()); }, 'play'); } }); } // Disable right click if (player.supported.ui && player.config.disableContextMenu) { on.call(player, elements.wrapper, 'contextmenu', event => { event.preventDefault(); }, false); } // Volume change on.call(player, player.media, 'volumechange', () => { // Save to storage player.storage.set({ volume: player.volume, muted: player.muted }); }); // Speed change on.call(player, player.media, 'ratechange', () => { // Update UI controls.updateSetting.call(player, 'speed'); // Save to storage player.storage.set({ speed: player.speed }); }); // Quality change on.call(player, player.media, 'qualitychange', event => { // Update UI controls.updateSetting.call(player, 'quality', null, event.detail.quality); }); // Update download link when ready and if quality changes on.call(player, player.media, 'ready qualitychange', () => { controls.setDownloadUrl.call(player); }); // Proxy events to container // Bubble up key events for Edge const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' '); on.call(player, player.media, proxyEvents, event => { let { detail = {} } = event; // Get error details from media if (event.type === 'error') { detail = player.media.error; } triggerEvent.call(player, elements.container, event.type, true, detail); }); }); // Run default and custom handlers _defineProperty$1(this, "proxy", (event, defaultHandler, customHandlerKey) => { const { player } = this; const customHandler = player.config.listeners[customHandlerKey]; const hasCustomHandler = is.function(customHandler); let returned = true; // Execute custom handler if (hasCustomHandler) { returned = customHandler.call(player, event); } // Only call default handler if not prevented in custom handler if (returned !== false && is.function(defaultHandler)) { defaultHandler.call(player, event); } }); // Trigger custom and default handlers _defineProperty$1(this, "bind", (element, type, defaultHandler, customHandlerKey, passive = true) => { const { player } = this; const customHandler = player.config.listeners[customHandlerKey]; const hasCustomHandler = is.function(customHandler); on.call(player, element, type, event => this.proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler); }); // Listen for control events _defineProperty$1(this, "controls", () => { const { player } = this; const { elements } = player; // IE doesn't support input event, so we fallback to change const inputEvent = browser.isIE ? 'change' : 'input'; // Play/pause toggle if (elements.buttons.play) { Array.from(elements.buttons.play).forEach(button => { this.bind(button, 'click', () => { silencePromise(player.togglePlay()); }, 'play'); }); } // Pause this.bind(elements.buttons.restart, 'click', player.restart, 'restart'); // Rewind this.bind(elements.buttons.rewind, 'click', () => { // Record seek time so we can prevent hiding controls for a few seconds after rewind player.lastSeekTime = Date.now(); player.rewind(); }, 'rewind'); // Rewind this.bind(elements.buttons.fastForward, 'click', () => { // Record seek time so we can prevent hiding controls for a few seconds after fast forward player.lastSeekTime = Date.now(); player.forward(); }, 'fastForward'); // Mute toggle this.bind(elements.buttons.mute, 'click', () => { player.muted = !player.muted; }, 'mute'); // Captions toggle this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions()); // Download this.bind(elements.buttons.download, 'click', () => { triggerEvent.call(player, player.media, 'download'); }, 'download'); // Fullscreen toggle this.bind(elements.buttons.fullscreen, 'click', () => { player.fullscreen.toggle(); }, 'fullscreen'); // Picture-in-Picture this.bind(elements.buttons.pip, 'click', () => { player.pip = 'toggle'; }, 'pip'); // Airplay this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay'); // Settings menu - click toggle this.bind(elements.buttons.settings, 'click', event => { // Prevent the document click listener closing the menu event.stopPropagation(); event.preventDefault(); controls.toggleMenu.call(player, event); }, null, false); // Can't be passive as we're preventing default // Settings menu - keyboard toggle // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 this.bind(elements.buttons.settings, 'keyup', event => { if (![' ', 'Enter'].includes(event.key)) { return; } // Because return triggers a click anyway, all we need to do is set focus if (event.key === 'Enter') { controls.focusFirstMenuItem.call(player, null, true); return; } // Prevent scroll event.preventDefault(); // Prevent playing video (Firefox) event.stopPropagation(); // Toggle menu controls.toggleMenu.call(player, event); }, null, false // Can't be passive as we're preventing default ); // Escape closes menu this.bind(elements.settings.menu, 'keydown', event => { if (event.key === 'Escape') { controls.toggleMenu.call(player, event); } }); // Set range input alternative "value", which matches the tooltip time (#954) this.bind(elements.inputs.seek, 'mousedown mousemove', event => { const rect = elements.progress.getBoundingClientRect(); const percent = 100 / rect.width * (event.pageX - rect.left); event.currentTarget.setAttribute('seek-value', percent); }); // Pause while seeking this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => { const seek = event.currentTarget; const attribute = 'play-on-seeked'; if (is.keyboardEvent(event) && !['ArrowLeft', 'ArrowRight'].includes(event.key)) { return; } // Record seek time so we can prevent hiding controls for a few seconds after seek player.lastSeekTime = Date.now(); // Was playing before? const play = seek.hasAttribute(attribute); // Done seeking const done = ['mouseup', 'touchend', 'keyup'].includes(event.type); // If we're done seeking and it was playing, resume playback if (play && done) { seek.removeAttribute(attribute); silencePromise(player.play()); } else if (!done && player.playing) { seek.setAttribute(attribute, ''); player.pause(); } }); // Fix range inputs on iOS // Super weird iOS bug where after you interact with an , // it takes over further interactions on the page. This is a hack if (browser.isIos) { const inputs = getElements.call(player, 'input[type="range"]'); Array.from(inputs).forEach(input => this.bind(input, inputEvent, event => repaint(event.target))); } // Seek this.bind(elements.inputs.seek, inputEvent, event => { const seek = event.currentTarget; // If it exists, use seek-value instead of "value" for consistency with tooltip time (#954) let seekTo = seek.getAttribute('seek-value'); if (is.empty(seekTo)) { seekTo = seek.value; } seek.removeAttribute('seek-value'); player.currentTime = seekTo / seek.max * player.duration; }, 'seek'); // Seek tooltip this.bind(elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(player, event)); // Preview thumbnails plugin // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this this.bind(elements.progress, 'mousemove touchmove', event => { const { previewThumbnails } = player; if (previewThumbnails && previewThumbnails.loaded) { previewThumbnails.startMove(event); } }); // Hide thumbnail preview - on mouse click, mouse leave, and video play/seek. All four are required, e.g., for buffering this.bind(elements.progress, 'mouseleave touchend click', () => { const { previewThumbnails } = player; if (previewThumbnails && previewThumbnails.loaded) { previewThumbnails.endMove(false, true); } }); // Show scrubbing preview this.bind(elements.progress, 'mousedown touchstart', event => { const { previewThumbnails } = player; if (previewThumbnails && previewThumbnails.loaded) { previewThumbnails.startScrubbing(event); } }); this.bind(elements.progress, 'mouseup touchend', event => { const { previewThumbnails } = player; if (previewThumbnails && previewThumbnails.loaded) { previewThumbnails.endScrubbing(event); } }); // Polyfill for lower fill in for webkit if (browser.isWebKit) { Array.from(getElements.call(player, 'input[type="range"]')).forEach(element => { this.bind(element, 'input', event => controls.updateRangeFill.call(player, event.target)); }); } // Current time invert // Only if one time element is used for both currentTime and duration if (player.config.toggleInvert && !is.element(elements.display.duration)) { this.bind(elements.display.currentTime, 'click', () => { // Do nothing if we're at the start if (player.currentTime === 0) { return; } player.config.invertTime = !player.config.invertTime; controls.timeUpdate.call(player); }); } // Volume this.bind(elements.inputs.volume, inputEvent, event => { player.volume = event.target.value; }, 'volume'); // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting) this.bind(elements.controls, 'mouseenter mouseleave', event => { elements.controls.hover = !player.touch && event.type === 'mouseenter'; }); // Also update controls.hover state for any non-player children of fullscreen element (as above) if (elements.fullscreen) { Array.from(elements.fullscreen.children).filter(c => !c.contains(elements.container)).forEach(child => { this.bind(child, 'mouseenter mouseleave', event => { if (elements.controls) { elements.controls.hover = !player.touch && event.type === 'mouseenter'; } }); }); } // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting) this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type); }); // Show controls when they receive focus (e.g., when using keyboard tab key) this.bind(elements.controls, 'focusin', () => { const { config, timers } = player; // Skip transition to prevent focus from scrolling the parent element toggleClass(elements.controls, config.classNames.noTransition, true); // Toggle ui.toggleControls.call(player, true); // Restore transition setTimeout(() => { toggleClass(elements.controls, config.classNames.noTransition, false); }, 0); // Delay a little more for mouse users const delay = this.touch ? 3000 : 4000; // Clear timer clearTimeout(timers.controls); // Hide again after delay timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay); }); // Mouse wheel for volume this.bind(elements.inputs.volume, 'wheel', event => { // Detect "natural" scroll - supported on OS X Safari only // Other browsers on OS X will be inverted until support improves const inverted = event.webkitDirectionInvertedFromDevice; // Get delta from event. Invert if `inverted` is true 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) const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y); // Change the volume by 2% player.increaseVolume(direction / 50); // Don't break page scrolling at max and min const { volume } = player.media; if (direction === 1 && volume < 1 || direction === -1 && volume > 0) { event.preventDefault(); } }, 'volume', false); }); this.player = _player; this.lastKey = null; this.focusTimer = null; this.lastKeyDown = null; this.handleKey = this.handleKey.bind(this); this.toggleMenu = this.toggleMenu.bind(this); this.firstTouch = this.firstTouch.bind(this); } // Handle key presses handleKey(event) { const { player } = this; const { elements } = player; const { key, type, altKey, ctrlKey, metaKey, shiftKey } = event; const pressed = type === 'keydown'; const repeat = pressed && key === this.lastKey; // Bail if a modifier key is set if (altKey || ctrlKey || metaKey || shiftKey) { return; } // If the event is bubbled from the media element // Firefox doesn't get the key for whatever reason if (!key) { return; } // Seek by increment const seekByIncrement = increment => { // Divide the max duration into 10th's and times by the number value player.currentTime = player.duration / 10 * increment; }; // Handle the key on keydown // Reset on keyup if (pressed) { // Check focused element // and if the focused element is not editable (e.g. text input) // and any that accept key input http://webaim.org/techniques/keyboard/ const focused = document.activeElement; if (is.element(focused)) { const { editable } = player.config.selectors; const { seek } = elements.inputs; if (focused !== seek && matches(focused, editable)) { return; } if (event.key === ' ' && matches(focused, 'button, [role^="menuitem"]')) { return; } } // Which keys should we prevent default const preventDefault = [' ', '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) if (preventDefault.includes(key)) { event.preventDefault(); event.stopPropagation(); } switch (key) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': if (!repeat) { seekByIncrement(parseInt(key, 10)); } break; case ' ': case 'k': if (!repeat) { silencePromise(player.togglePlay()); } break; case 'ArrowUp': player.increaseVolume(0.1); break; case 'ArrowDown': player.decreaseVolume(0.1); break; case 'm': if (!repeat) { player.muted = !player.muted; } break; case 'ArrowRight': player.forward(); break; case 'ArrowLeft': player.rewind(); break; case 'f': player.fullscreen.toggle(); break; case 'c': if (!repeat) { player.toggleCaptions(); } break; case 'l': player.loop = !player.loop; break; } // Escape is handle natively when in full screen // So we only need to worry about non native if (key === 'Escape' && !player.fullscreen.usingNative && player.fullscreen.active) { player.fullscreen.toggle(); } // Store last key for next cycle this.lastKey = key; } else { this.lastKey = null; } } // Toggle menu toggleMenu(event) { controls.toggleMenu.call(this.player, event); } } var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function createCommonjsModule(fn, module) { return module = { exports: {} }, fn(module, module.exports), module.exports; } var loadjs_umd = createCommonjsModule(function (module, exports) { (function (root, factory) { { module.exports = factory(); } })(commonjsGlobal, function () { /** * Global dependencies. * @global {Object} document - DOM */ var devnull = function () {}, bundleIdCache = {}, bundleResultCache = {}, bundleCallbackQueue = {}; /** * Subscribe to bundle load event. * @param {string[]} bundleIds - Bundle ids * @param {Function} callbackFn - The callback function */ function subscribe(bundleIds, callbackFn) { // listify bundleIds = bundleIds.push ? bundleIds : [bundleIds]; var depsNotFound = [], i = bundleIds.length, numWaiting = i, fn, bundleId, r, q; // define callback function fn = function (bundleId, pathsNotFound) { if (pathsNotFound.length) depsNotFound.push(bundleId); numWaiting--; if (!numWaiting) callbackFn(depsNotFound); }; // register callback while (i--) { bundleId = bundleIds[i]; // execute callback if in result cache r = bundleResultCache[bundleId]; if (r) { fn(bundleId, r); continue; } // add to callback queue q = bundleCallbackQueue[bundleId] = bundleCallbackQueue[bundleId] || []; q.push(fn); } } /** * Publish bundle load event. * @param {string} bundleId - Bundle id * @param {string[]} pathsNotFound - List of files not found */ function publish(bundleId, pathsNotFound) { // exit if id isn't defined if (!bundleId) return; var q = bundleCallbackQueue[bundleId]; // cache result bundleResultCache[bundleId] = pathsNotFound; // exit if queue is empty if (!q) return; // empty callback queue while (q.length) { q[0](bundleId, pathsNotFound); q.splice(0, 1); } } /** * Execute callbacks. * @param {Object or Function} args - The callback args * @param {string[]} depsNotFound - List of dependencies not found */ function executeCallbacks(args, depsNotFound) { // accept function as argument if (args.call) args = { success: args }; // success and error callbacks if (depsNotFound.length) (args.error || devnull)(depsNotFound);else (args.success || devnull)(args); } /** * Load individual file. * @param {string} path - The file path * @param {Function} callbackFn - The callback function */ function loadFile(path, callbackFn, args, numTries) { var doc = document, async = args.async, maxTries = (args.numRetries || 0) + 1, beforeCallbackFn = args.before || devnull, pathname = path.replace(/[\?|#].*$/, ''), pathStripped = path.replace(/^(css|img)!/, ''), isLegacyIECss, e; numTries = numTries || 0; if (/(^css!|\.css$)/.test(pathname)) { // css e = doc.createElement('link'); e.rel = 'stylesheet'; e.href = pathStripped; // tag IE9+ isLegacyIECss = 'hideFocus' in e; // use preload in IE Edge (to detect load errors) if (isLegacyIECss && e.relList) { isLegacyIECss = 0; e.rel = 'preload'; e.as = 'style'; } } else if (/(^img!|\.(png|gif|jpg|svg|webp)$)/.test(pathname)) { // image e = doc.createElement('img'); e.src = pathStripped; } else { // javascript e = doc.createElement('script'); e.src = path; e.async = async === undefined ? true : async; } e.onload = e.onerror = e.onbeforeload = function (ev) { var result = ev.type[0]; // treat empty stylesheets as failures to get around lack of onerror // support in IE9-11 if (isLegacyIECss) { try { if (!e.sheet.cssText.length) result = 'e'; } catch (x) { // sheets objects created from load errors don't allow access to // `cssText` (unless error is Code:18 SecurityError) if (x.code != 18) result = 'e'; } } // handle retries in case of load failure if (result == 'e') { // increment counter numTries += 1; // exit function and try again if (numTries < maxTries) { return loadFile(path, callbackFn, args, numTries); } } else if (e.rel == 'preload' && e.as == 'style') { // activate preloaded stylesheets return e.rel = 'stylesheet'; // jshint ignore:line } // execute callback callbackFn(path, result, ev.defaultPrevented); }; // add to document (unless callback returns `false`) if (beforeCallbackFn(path, e) !== false) doc.head.appendChild(e); } /** * Load multiple files. * @param {string[]} paths - The file paths * @param {Function} callbackFn - The callback function */ function loadFiles(paths, callbackFn, args) { // listify paths paths = paths.push ? paths : [paths]; var numWaiting = paths.length, x = numWaiting, pathsNotFound = [], fn, i; // define callback function fn = function (path, result, defaultPrevented) { // handle error if (result == 'e') pathsNotFound.push(path); // handle beforeload event. If defaultPrevented then that means the load // will be blocked (ex. Ghostery/ABP on Safari) if (result == 'b') { if (defaultPrevented) pathsNotFound.push(path);else return; } numWaiting--; if (!numWaiting) callbackFn(pathsNotFound); }; // load scripts for (i = 0; i < x; i++) loadFile(paths[i], fn, args); } /** * Initiate script load and register bundle. * @param {(string|string[])} paths - The file paths * @param {(string|Function|Object)} [arg1] - The (1) bundleId or (2) success * callback or (3) object literal with success/error arguments, numRetries, * etc. * @param {(Function|Object)} [arg2] - The (1) success callback or (2) object * literal with success/error arguments, numRetries, etc. */ function loadjs(paths, arg1, arg2) { var bundleId, args; // bundleId (if string) if (arg1 && arg1.trim) bundleId = arg1; // args (default is {}) args = (bundleId ? arg2 : arg1) || {}; // throw error if bundle is already defined if (bundleId) { if (bundleId in bundleIdCache) { throw "LoadJS"; } else { bundleIdCache[bundleId] = true; } } function loadFn(resolve, reject) { loadFiles(paths, function (pathsNotFound) { // execute callbacks executeCallbacks(args, pathsNotFound); // resolve Promise if (resolve) { executeCallbacks({ success: resolve, error: reject }, pathsNotFound); } // publish bundle load event publish(bundleId, pathsNotFound); }, args); } if (args.returnPromise) return new Promise(loadFn);else loadFn(); } /** * Execute callbacks when dependencies have been satisfied. * @param {(string|string[])} deps - List of bundle ids * @param {Object} args - success/error arguments */ loadjs.ready = function ready(deps, args) { // subscribe to bundle load event subscribe(deps, function (depsNotFound) { // execute callbacks executeCallbacks(args, depsNotFound); }); return loadjs; }; /** * Manually satisfy bundle dependencies. * @param {string} bundleId - The bundle id */ loadjs.done = function done(bundleId) { publish(bundleId, []); }; /** * Reset loadjs dependencies statuses */ loadjs.reset = function reset() { bundleIdCache = {}; bundleResultCache = {}; bundleCallbackQueue = {}; }; /** * Determine if bundle has already been defined * @param String} bundleId - The bundle id */ loadjs.isDefined = function isDefined(bundleId) { return bundleId in bundleIdCache; }; // export return loadjs; }); }); // ========================================================================== // Load an external script // ========================================================================== function loadScript(url) { return new Promise((resolve, reject) => { loadjs_umd(url, { success: resolve, error: reject }); }); } // ========================================================================== // Vimeo plugin // ========================================================================== // Parse Vimeo ID from URL function parseId$1(url) { if (is.empty(url)) { return null; } if (is.number(Number(url))) { return url; } const regex = /^.*(vimeo.com\/|video\/)(\d+).*/; return url.match(regex) ? RegExp.$2 : url; } // Try to extract a hash for private videos from the URL function parseHash(url) { /* This regex matches a hexadecimal hash if given in any of these forms: * - [https://player.]vimeo.com/video/{id}/{hash}[?params] * - [https://player.]vimeo.com/video/{id}?h={hash}[¶ms] * - [https://player.]vimeo.com/video/{id}?[params]&h={hash} * - video/{id}/{hash} * If matched, the hash is available in capture group 4 */ const regex = /^.*(vimeo.com\/|video\/)(\d+)(\?.*&*h=|\/)+([\d,a-f]+)/; const found = url.match(regex); return found && found.length === 5 ? found[4] : null; } // Set playback state and trigger change (only on actual change) function assurePlaybackState$1(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'); } } const vimeo = { setup() { const player = this; // Add embed class for responsive toggleClass(player.elements.wrapper, player.config.classNames.embed, true); // Set speed options from config player.options.speed = player.config.speed.options; // Set intial ratio setAspectRatio.call(player); // Load the SDK if not already if (!is.object(window.Vimeo)) { loadScript(player.config.urls.vimeo.sdk).then(() => { vimeo.ready.call(player); }).catch(error => { player.debug.warn('Vimeo SDK (player.js) failed to load', error); }); } else { vimeo.ready.call(player); } }, // API Ready ready() { const player = this; const config = player.config.vimeo; const { premium, referrerPolicy, ...frameParams } = config; // Get the source URL or ID let source = player.media.getAttribute('src'); let hash = ''; // Get from
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: player.config.playsinline, // 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.setMuted(toggle ? true : player.config.muted).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); } } }; // ========================================================================== // YouTube plugin // ========================================================================== // Parse YouTube ID from URL 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