plyr
Version:
A simple, accessible and customizable HTML5, YouTube and Vimeo media player
1,542 lines (1,376 loc) • 302 kB
JavaScript
typeof navigator === "object" && (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define('Plyr', factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Plyr = factory());
})(this, (function () { 'use strict';
function _defineProperty$1(e, r, t) {
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
value: t,
enumerable: true,
configurable: true,
writable: true
}) : e[r] = t, e;
}
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);
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 : i + "";
}
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 || false, r.configurable = true, "value" in r && (r.writable = true), 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: true,
configurable: true,
writable: true
}) : 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), true).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: true,
thumbWidth: 15,
watch: true
};
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: true
});
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(true), 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(false), 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);
}, false);
});
}
}, {
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: true,
subtree: true
});
}
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);
function isElement(input) {
return input !== null && typeof input === 'object' && input.nodeType === 1 && typeof input.style === 'object' && typeof input.ownerDocument === 'object';
}
function isEmpty(input) {
return isNullOrUndefined(input) || (isString(input) || isArray(input) || isNodeList(input)) && !input.length || isObject(input) && !Object.keys(input).length;
}
function 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 {
element.hidden = true;
// eslint-disable-next-line no-unused-expressions
element.offsetHeight;
element.hidden = false;
} catch {}
}, delay);
}
// ==========================================================================
// 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 <element>
const element = document.createElement(type);
// Set all passed attributes
if (is.object(attributes)) {
setAttributes(element, attributes);
}
// Add text node
if (is.string(text)) {
element.textContent = 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;
}
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
pip: (() => {
return document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture;
})(),
// 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'),
// <input type="range"> 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 {}
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 <div> to hide the standard controls and UI
if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) {
const height = 100 / this.media.offsetWidth * Number.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 <source> elements
return html5.getSources.call(this).map(source => Number(source.getAttribute('size'))).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('size'));
},
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('size')) === 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');
}
};
// ==========================================================================
// Browser sniffing
// Unfortunately, due to mixed support, UA sniffing is required
// ==========================================================================
const isIE = Boolean(window.document.documentMode);
const isEdge = /Edge/.test(navigator.userAgent);
const isWebKit = 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent);
// navigator.platform may be deprecated but this check is still required
const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
const isIos = /iPad|iPhone|iPod/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1;
var browser = {
isIE,
isEdge,
isWebKit,
isIPadOS,
isIos
};
// ==========================================================================
// 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
function replaceAll(input = '', find = '', replace = '') {
return input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString());
}
// Convert to title case
function toTitleCase(input = '') {
return 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.textContent;
}
// 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 objects
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 {}
});
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', withCredentials = false) {
return new Promise((resolve, reject) => {
try {
const request = new XMLHttpRequest();
// Check for CORS support
if (!('withCredentials' in request)) return;
// Set to true if needed for CORS
if (withCredentials) {
request.withCredentials = true;
}
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);
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) => {
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 {}
}
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 <svg> 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 <svg>
const icon = document.createElementNS(namespace, 'svg');
setAttributes(icon, extend(attributes, {
'aria-hidden': 'true',
'focusable': 'false'
}));
// Create the <use> 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 <use> to <svg>
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(createEle