Merge branch 'develop' into a11y-improvements

# Conflicts:
#	dist/plyr.js
#	dist/plyr.js.map
#	dist/plyr.min.js
#	dist/plyr.min.js.map
#	dist/plyr.polyfilled.js
#	dist/plyr.polyfilled.js.map
#	dist/plyr.polyfilled.min.js
#	dist/plyr.polyfilled.min.js.map
#	src/js/controls.js
#	src/js/fullscreen.js
#	src/js/plyr.js
#	src/js/ui.js
#	src/js/utils.js
This commit is contained in:
Sam Potts
2018-06-17 01:26:24 +10:00
51 changed files with 27362 additions and 27557 deletions

30
src/js/utils/animation.js Normal file
View File

@ -0,0 +1,30 @@
// ==========================================================================
// Animation utils
// ==========================================================================
import { toggleHidden } from './elements';
import is from './is';
export 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
export function repaint(element) {
setTimeout(() => {
toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line
toggleHidden(element, false);
}, 0);
}

23
src/js/utils/arrays.js Normal file
View File

@ -0,0 +1,23 @@
// ==========================================================================
// Array utils
// ==========================================================================
import is from './is';
// Remove duplicates in an array
export 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
export 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));
}

13
src/js/utils/browser.js Normal file
View File

@ -0,0 +1,13 @@
// ==========================================================================
// Browser sniffing
// Unfortunately, due to mixed support, UA sniffing is required
// ==========================================================================
const browser = {
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),
};
export default browser;

285
src/js/utils/elements.js Normal file
View File

@ -0,0 +1,285 @@
// ==========================================================================
// Element utils
// ==========================================================================
import { toggleListener } from './events';
import is from './is';
// Wrap an element
export 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
export 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
export 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.innerText = text;
}
// Return built element
return element;
}
// Inaert an element after another
export function insertAfter(element, target) {
target.parentNode.insertBefore(element, target.nextSibling);
}
// Insert a DocumentFragment
export function insertElement(type, parent, attributes, text) {
// Inject the new <element>
parent.appendChild(createElement(type, attributes, text));
}
// Remove element(s)
export 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
export function emptyElement(element) {
let { length } = element.childNodes;
while (length > 0) {
element.removeChild(element.lastChild);
length -= 1;
}
}
// Replace element
export 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
export 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 = 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 (is.object(existing) && 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 hidden
export function toggleHidden(element, hidden) {
if (!is.element(element)) {
return;
}
let hide = hidden;
if (!is.boolean(hide)) {
hide = !element.hasAttribute('hidden');
}
if (hide) {
element.setAttribute('hidden', '');
} else {
element.removeAttribute('hidden');
}
}
// Mirror Element.classList.toggle, with IE compatibility for "force" argument
export function toggleClass(element, 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 null;
}
// Has class name
export function hasClass(element, className) {
return is.element(element) && element.classList.contains(className);
}
// Element matches selector
export function 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
export function getElements(selector) {
return this.elements.container.querySelectorAll(selector);
}
// Find a single element
export function getElement(selector) {
return this.elements.container.querySelector(selector);
}
// Get the focused element
export function getFocusElement() {
let focused = document.activeElement;
if (!focused || focused === document.body) {
focused = null;
} else {
focused = document.querySelector(':focus');
}
return focused;
}
// Trap focus inside container
export function trapFocus(element = null, toggle = false) {
if (!is.element(element)) {
return;
}
const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
const first = focusable[0];
const last = focusable[focusable.length - 1];
const trap = event => {
// Bail if not tab key or not fullscreen
if (event.key !== 'Tab' || event.keyCode !== 9) {
return;
}
// Get the current focused element
const focused = getFocusElement();
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();
}
};
toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false);
}

120
src/js/utils/events.js Normal file
View File

@ -0,0 +1,120 @@
// ==========================================================================
// Event utils
// ==========================================================================
import is from './is';
// 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 (e) {
// Do nothing
}
return supported;
})();
// Toggle event listener
export 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
export function on(element, events = '', callback, passive = true, capture = false) {
toggleListener.call(this, element, events, callback, true, passive, capture);
}
// Unbind event handler
export function off(element, events = '', callback, passive = true, capture = false) {
toggleListener.call(this, element, events, callback, false, passive, capture);
}
// Bind once-only event handler
export function once(element, events = '', callback, passive = true, capture = false) {
function onceCallback(...args) {
off(element, events, onceCallback, passive, capture);
callback.apply(this, args);
}
toggleListener.call(this, element, events, onceCallback, true, passive, capture);
}
// Trigger event
export 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: Object.assign({}, detail, {
plyr: this,
}),
});
// Dispatch the event
element.dispatchEvent(event);
}
// Unbind all cached event listeners
export 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
export function ready() {
return new Promise(
resolve => (this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)),
).then(() => {});
}

42
src/js/utils/fetch.js Normal file
View File

@ -0,0 +1,42 @@
// ==========================================================================
// Fetch wrapper
// Using XHR to avoid issues with older browsers
// ==========================================================================
export default 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 (e) {
resolve(request.responseText);
}
} else {
resolve(request.response);
}
});
request.addEventListener('error', () => {
throw new Error(request.statusText);
});
request.open('GET', url, true);
// Set the required response type
request.responseType = responseType;
request.send();
} catch (e) {
reject(e);
}
});
}

67
src/js/utils/is.js Normal file
View File

@ -0,0 +1,67 @@
// ==========================================================================
// 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 is = {
object(input) {
return getConstructor(input) === Object;
},
number(input) {
return getConstructor(input) === Number && !Number.isNaN(input);
},
string(input) {
return getConstructor(input) === String;
},
boolean(input) {
return getConstructor(input) === Boolean;
},
function(input) {
return getConstructor(input) === Function;
},
array(input) {
return !is.nullOrUndefined(input) && Array.isArray(input);
},
weakMap(input) {
return instanceOf(input, WeakMap);
},
nodeList(input) {
return instanceOf(input, NodeList);
},
element(input) {
return instanceOf(input, Element);
},
textNode(input) {
return getConstructor(input) === Text;
},
event(input) {
return instanceOf(input, Event);
},
cue(input) {
return instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);
},
track(input) {
return instanceOf(input, TextTrack) || (!is.nullOrUndefined(input) && is.string(input.kind));
},
url(input) {
return (
!is.nullOrUndefined(input) &&
/(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input)
);
},
nullOrUndefined(input) {
return input === null || typeof input === 'undefined';
},
empty(input) {
return (
is.nullOrUndefined(input) ||
((is.string(input) || is.array(input) || is.nodeList(input)) && !input.length) ||
(is.object(input) && !Object.keys(input).length)
);
},
};
export default is;

19
src/js/utils/loadImage.js Normal file
View File

@ -0,0 +1,19 @@
// ==========================================================================
// 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
// ==========================================================================
export default 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 });
});
}

View File

@ -0,0 +1,14 @@
// ==========================================================================
// Load an external script
// ==========================================================================
import loadjs from 'loadjs';
export default function loadScript(url) {
return new Promise((resolve, reject) => {
loadjs(url, {
success: resolve,
error: reject,
});
});
}

View File

@ -0,0 +1,75 @@
// ==========================================================================
// Sprite loader
// ==========================================================================
import Storage from './../storage';
import is from './is';
// Load an external SVG sprite
export default 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) {
window.localStorage.setItem(
`${prefix}-${id}`,
JSON.stringify({
content: result,
}),
);
}
update(container, result);
})
.catch(() => {});
}
}

42
src/js/utils/objects.js Normal file
View File

@ -0,0 +1,42 @@
// ==========================================================================
// Object utils
// ==========================================================================
import is from './is';
// Clone nested objects
export function cloneDeep(object) {
return JSON.parse(JSON.stringify(object));
}
// Get a nested value in an object
export function getDeep(object, path) {
return path.split('.').reduce((obj, key) => obj && obj[key], object);
}
// Deep extend destination object with N more objects
export 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);
}

85
src/js/utils/strings.js Normal file
View File

@ -0,0 +1,85 @@
// ==========================================================================
// String utils
// ==========================================================================
import is from './is';
// Generate a random ID
export function generateId(prefix) {
return `${prefix}-${Math.floor(Math.random() * 10000)}`;
}
// Format string
export function format(input, ...args) {
if (is.empty(input)) {
return input;
}
return input.toString().replace(/{(\d+)}/g, (match, i) => args[i].toString());
}
// Get percentage
export function getPercentage(current, max) {
if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
return 0;
}
return (current / max * 100).toFixed(2);
}
// Replace all occurances of a string in a string
export function replaceAll(input = '', find = '', replace = '') {
return input.replace(
new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'),
replace.toString(),
);
}
// Convert to title case
export function toTitleCase(input = '') {
return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase());
}
// Convert string to pascalCase
export 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
export 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
export 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
export function getHTML(element) {
const wrapper = document.createElement('div');
wrapper.appendChild(element);
return wrapper.innerHTML;
}

36
src/js/utils/time.js Normal file
View File

@ -0,0 +1,36 @@
// ==========================================================================
// Time utils
// ==========================================================================
import is from './is';
// Time helpers
export const getHours = value => parseInt((value / 60 / 60) % 60, 10);
export const getMinutes = value => parseInt((value / 60) % 60, 10);
export const getSeconds = value => parseInt(value % 60, 10);
// Format time to UI friendly string
export function formatTime(time = 0, displayHours = false, inverted = false) {
// Bail if the value isn't a number
if (!is.number(time)) {
return formatTime(null, 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)}`;
}

39
src/js/utils/urls.js Normal file
View File

@ -0,0 +1,39 @@
// ==========================================================================
// URL utils
// ==========================================================================
import is from './is';
/**
* Parse a string to a URL object
* @param {string} input - the URL to be parsed
* @param {boolean} safe - failsafe parsing
*/
export 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 (e) {
return null;
}
}
// Convert object to URLSearchParams
export function buildUrlParams(input) {
const params = new URLSearchParams();
if (is.object(input)) {
Object.entries(input).forEach(([key, value]) => {
params.set(key, value);
});
}
return params;
}