diff options
Diffstat (limited to 'src/js/utils.js')
-rw-r--r-- | src/js/utils.js | 667 |
1 files changed, 667 insertions, 0 deletions
diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 00000000..e81954f4 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,667 @@ +// ========================================================================== +// Plyr utils +// ========================================================================== + +import support from './support'; + +const utils = { + // Check variable types + is: { + object(input) { + return this.getConstructor(input) === Object; + }, + number(input) { + return this.getConstructor(input) === Number && !Number.isNaN(input); + }, + string(input) { + return this.getConstructor(input) === String; + }, + boolean(input) { + return this.getConstructor(input) === Boolean; + }, + function(input) { + return this.getConstructor(input) === Function; + }, + array(input) { + return !this.undefined(input) && Array.isArray(input); + }, + nodeList(input) { + return !this.undefined(input) && input instanceof NodeList; + }, + htmlElement(input) { + return !this.undefined(input) && input instanceof HTMLElement; + }, + event(input) { + return !this.undefined(input) && input instanceof Event; + }, + cue(input) { + return this.instanceOf(input, window.TextTrackCue) || this.instanceOf(input, window.VTTCue); + }, + track(input) { + return ( + !this.undefined(input) && (this.instanceOf(input, window.TextTrack) || typeof input.kind === 'string') + ); + }, + undefined(input) { + return input !== null && typeof input === 'undefined'; + }, + empty(input) { + return ( + input === null || + typeof input === 'undefined' || + ((this.string(input) || this.array(input) || this.nodeList(input)) && input.length === 0) || + (this.object(input) && Object.keys(input).length === 0) + ); + }, + getConstructor(input) { + if (input === null || typeof input === 'undefined') { + return null; + } + + return input.constructor; + }, + instanceOf(input, constructor) { + return Boolean(input && constructor && input instanceof constructor); + }, + }, + + // Unfortunately, due to mixed support, UA sniffing is required + getBrowser() { + return { + isIE: /* @cc_on!@ */ false || !!document.documentMode, + isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent), + isIPhone: /(iPhone|iPod)/gi.test(navigator.platform), + isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform), + }; + }, + + // Load an external script + loadScript(url) { + // Check script is not already referenced + if (document.querySelectorAll(`script[src="${url}"]`).length) { + return; + } + + const tag = document.createElement('script'); + tag.src = url; + + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + }, + + // Generate a random ID + generateId(prefix) { + return `${prefix}-${Math.floor(Math.random() * 10000)}`; + }, + + // Determine if we're in an iframe + inFrame() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } + }, + + // Wrap an element + 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); + } + }); + }, + + // Remove an element + removeElement(element) { + if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) { + return null; + } + + element.parentNode.removeChild(element); + + return element; + }, + + // Inaert an element after another + insertAfter(element, target) { + target.parentNode.insertBefore(element, target.nextSibling); + }, + + // Create a DocumentFragment + createElement(type, attributes, text) { + // Create a new <element> + const element = document.createElement(type); + + // Set all passed attributes + if (utils.is.object(attributes)) { + utils.setAttributes(element, attributes); + } + + // Add text node + if (utils.is.string(text)) { + element.textContent = text; + } + + // Return built element + return element; + }, + + // Insert a DocumentFragment + insertElement(type, parent, attributes, text) { + // Inject the new <element> + parent.appendChild(utils.createElement(type, attributes, text)); + }, + + // Remove all child elements + emptyElement(element) { + let { length } = element.childNodes; + + while (length > 0) { + element.removeChild(element.lastChild); + length -= 1; + } + }, + + // Set attributes + setAttributes(element, attributes) { + Object.keys(attributes).forEach(key => { + element.setAttribute(key, attributes[key]); + }); + }, + + // Get an attribute object from a string selector + getAttributesFromSelector(sel, existingAttributes) { + // For example: + // '.test' to { class: 'test' } + // '#test' to { id: 'test' } + // '[data-test="test"]' to { 'data-test': 'test' } + + if (!utils.is.string(sel) || utils.is.empty(sel)) { + return {}; + } + + const attributes = {}; + const existing = 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[0]; + 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 (utils.is.object(existing) && utils.is.string(existing.class)) { + existing.class += ` ${className}`; + } + + attributes.class = className; + break; + + case '#': + // ID selector + attributes.id = selector.replace('#', ''); + break; + + case '[': + // Attribute selector + attributes[key] = value; + + break; + + default: + break; + } + }); + + return attributes; + }, + + // Toggle class on an element + toggleClass(element, className, toggle) { + if (utils.is.htmlElement(element)) { + const contains = element.classList.contains(className); + + element.classList[toggle ? 'add' : 'remove'](className); + + return (toggle && !contains) || (!toggle && contains); + } + + return null; + }, + + // Has class name + hasClass(element, className) { + return utils.is.htmlElement(element) && element.classList.contains(className); + }, + + // Element matches selector + matches(element, selector) { + const prototype = { Element }; + + function match() { + return Array.from(document.querySelectorAll(selector)).includes(this); + } + + const matches = + prototype.matches || + prototype.webkitMatchesSelector || + prototype.mozMatchesSelector || + prototype.msMatchesSelector || + match; + + return matches.call(element, selector); + }, + + // Find all elements + getElements(selector) { + return this.elements.container.querySelectorAll(selector); + }, + + // Find a single element + getElement(selector) { + return this.elements.container.querySelector(selector); + }, + + // Find the UI controls and store references in custom controls + // TODO: Allow settings menus with custom controls + findElements() { + try { + this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper); + + // Buttons + this.elements.buttons = { + play: utils.getElements.call(this, this.config.selectors.buttons.play), + pause: utils.getElement.call(this, this.config.selectors.buttons.pause), + restart: utils.getElement.call(this, this.config.selectors.buttons.restart), + rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind), + forward: utils.getElement.call(this, this.config.selectors.buttons.forward), + mute: utils.getElement.call(this, this.config.selectors.buttons.mute), + pip: utils.getElement.call(this, this.config.selectors.buttons.pip), + airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay), + settings: utils.getElement.call(this, this.config.selectors.buttons.settings), + captions: utils.getElement.call(this, this.config.selectors.buttons.captions), + fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen), + }; + + // Progress + this.elements.progress = utils.getElement.call(this, this.config.selectors.progress); + + // Inputs + this.elements.inputs = { + seek: utils.getElement.call(this, this.config.selectors.inputs.seek), + volume: utils.getElement.call(this, this.config.selectors.inputs.volume), + }; + + // Display + this.elements.display = { + buffer: utils.getElement.call(this, this.config.selectors.display.buffer), + duration: utils.getElement.call(this, this.config.selectors.display.duration), + currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime), + }; + + // Seek tooltip + if (utils.is.htmlElement(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector( + `.${this.config.classNames.tooltip}` + ); + } + + return true; + } catch (error) { + // Log it + this.warn('It looks like there is a problem with your custom controls HTML', error); + + // Restore native video controls + this.toggleNativeControls(true); + + return false; + } + }, + + // Get the focused element + getFocusElement() { + let focused = document.activeElement; + + if (!focused || focused === document.body) { + focused = null; + } else { + focused = document.querySelector(':focus'); + } + + return focused; + }, + + // Trap focus inside container + trapFocus() { + const tabbables = utils.getElements.call(this, 'input:not([disabled]), button:not([disabled])'); + const first = tabbables[0]; + const last = tabbables[tabbables.length - 1]; + + utils.on( + this.elements.container, + 'keydown', + event => { + // If it is tab + if (event.which === 9 && this.fullscreen.active) { + if (event.target === last && !event.shiftKey) { + // Move focus to first element that can be tabbed if Shift isn't used + event.preventDefault(); + first.focus(); + } else if (event.target === first && event.shiftKey) { + // Move focus to last element that can be tabbed if Shift is used + event.preventDefault(); + last.focus(); + } + } + }, + false + ); + }, + + // Bind along with custom handler + proxy(element, eventName, customListener, defaultListener, passive, capture) { + utils.on( + element, + eventName, + event => { + if (customListener) { + customListener.apply(element, [event]); + } + defaultListener.apply(element, [event]); + }, + passive, + capture + ); + }, + + // Toggle event listener + toggleListener(elements, event, callback, toggle, passive, capture) { + // Bail if no elements + if (elements === null || utils.is.undefined(elements)) { + return; + } + + // If a nodelist is passed, call itself on each node + if (elements instanceof NodeList) { + // Create listener for each node + Array.from(elements).forEach(element => { + if (element instanceof Node) { + utils.toggleListener.call(null, element, event, callback, toggle, passive, capture); + } + }); + + return; + } + + // Allow multiple events + const events = event.split(' '); + + // Build options + // Default to just capture boolean + let options = utils.is.boolean(capture) ? capture : false; + + // If passive events listeners are supported + if (support.passiveListeners) { + options = { + // Whether the listener can be passive (i.e. default never prevented) + passive: utils.is.boolean(passive) ? passive : true, + // Whether the listener is a capturing listener or not + capture: utils.is.boolean(capture) ? capture : false, + }; + } + + // If a single node is passed, bind the event listener + events.forEach(type => { + elements[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); + }, + + // Bind event handler + on(element, events, callback, passive, capture) { + utils.toggleListener(element, events, callback, true, passive, capture); + }, + + // Unbind event handler + off(element, events, callback, passive, capture) { + utils.toggleListener(element, events, callback, false, passive, capture); + }, + + // Trigger event + dispatchEvent(element, type, bubbles, properties) { + // Bail if no element + if (!element || !type) { + return; + } + + // Create and dispatch the event + const event = new CustomEvent(type, { + bubbles: utils.is.boolean(bubbles) ? bubbles : false, + detail: Object.assign({}, properties, { + plyr: this instanceof Plyr ? this : null, + }), + }); + + // Dispatch the event + element.dispatchEvent(event); + }, + + // Toggle aria-pressed state on a toggle button + // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles + toggleState(target, state) { + // Bail if no target + if (!target) { + return null; + } + + // Get state + const newState = utils.is.boolean(state) ? state : !target.getAttribute('aria-pressed'); + + // Set the attribute on target + target.setAttribute('aria-pressed', newState); + + return newState; + }, + + // Get percentage + getPercentage(current, max) { + if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { + return 0; + } + return (current / max * 100).toFixed(2); + }, + + // Deep extend/merge destination object with N more objects + // http://andrewdupont.net/2009/08/28/deep-extending-objects-in-javascript/ + // Removed call to arguments.callee (used explicit function name instead) + extend(...objects) { + const { length } = objects; + + // Bail if nothing to merge + if (!length) { + return null; + } + + // Return first if specified but nothing to merge + if (length === 1) { + return objects[0]; + } + + // First object is the destination + let destination = Array.prototype.shift.call(objects); + if (!utils.is.object(destination)) { + destination = {}; + } + + // Loop through all objects to merge + objects.forEach(source => { + if (!utils.is.object(source)) { + return; + } + + Object.keys(source).forEach(property => { + if (source[property] && source[property].constructor && source[property].constructor === Object) { + destination[property] = destination[property] || {}; + utils.extend(destination[property], source[property]); + } else { + destination[property] = source[property]; + } + }); + }); + + return destination; + }, + + // Parse YouTube ID from URL + parseYouTubeId(url) { + const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + return url.match(regex) ? RegExp.$2 : url; + }, + + // Parse Vimeo ID from URL + parseVimeoId(url) { + if (utils.is.number(Number(url))) { + return url; + } + + const regex = /^.*(vimeo.com\/|video\/)(\d+).*/; + return url.match(regex) ? RegExp.$2 : url; + }, + + // Convert object to URL parameters + buildUrlParameters(input) { + if (!utils.is.object(input)) { + return ''; + } + + return Object.keys(input) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(input[key])}`) + .join('&'); + }, + + // Remove HTML from a string + stripHTML(source) { + const fragment = document.createDocumentFragment(); + const element = document.createElement('div'); + fragment.appendChild(element); + element.innerHTML = source; + return fragment.firstChild.innerText; + }, + + // Load an SVG sprite + loadSprite(url, id) { + if (typeof url !== 'string') { + return; + } + + const prefix = 'cache-'; + const hasId = typeof id === 'string'; + let isCached = false; + + function updateSprite(data) { + // Inject content + this.innerHTML = data; + + // Inject the SVG to the body + document.body.insertBefore(this, document.body.childNodes[0]); + } + + // Only load once + if (!hasId || !document.querySelectorAll(`#${id}`).length) { + // Create container + const container = document.createElement('div'); + container.setAttribute('hidden', ''); + + if (hasId) { + container.setAttribute('id', id); + } + + // Check in cache + if (support.storage) { + const cached = window.localStorage.getItem(prefix + id); + isCached = cached !== null; + + if (isCached) { + const data = JSON.parse(cached); + updateSprite.call(container, data.content); + } + } + + // ReSharper disable once InconsistentNaming + const xhr = new XMLHttpRequest(); + + // XHR for Chrome/Firefox/Opera/Safari + if ('withCredentials' in xhr) { + xhr.open('GET', url, true); + } else { + return; + } + + // Once loaded, inject to container and body + xhr.onload = () => { + if (support.storage) { + window.localStorage.setItem( + prefix + id, + JSON.stringify({ + content: xhr.responseText, + }) + ); + } + + updateSprite.call(container, xhr.responseText); + }; + + xhr.send(); + } + }, + + // Get the transition end event + transitionEnd: (() => { + 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 typeof type === 'string' ? type : false; + })(), +}; + +export default utils; |