aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/utils')
-rw-r--r--src/js/utils/animation.js30
-rw-r--r--src/js/utils/arrays.js23
-rw-r--r--src/js/utils/browser.js13
-rw-r--r--src/js/utils/elements.js285
-rw-r--r--src/js/utils/events.js120
-rw-r--r--src/js/utils/fetch.js42
-rw-r--r--src/js/utils/is.js67
-rw-r--r--src/js/utils/loadImage.js19
-rw-r--r--src/js/utils/loadScript.js14
-rw-r--r--src/js/utils/loadSprite.js75
-rw-r--r--src/js/utils/objects.js42
-rw-r--r--src/js/utils/strings.js85
-rw-r--r--src/js/utils/time.js36
-rw-r--r--src/js/utils/urls.js39
14 files changed, 890 insertions, 0 deletions
diff --git a/src/js/utils/animation.js b/src/js/utils/animation.js
new file mode 100644
index 00000000..95e39f03
--- /dev/null
+++ b/src/js/utils/animation.js
@@ -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);
+}
diff --git a/src/js/utils/arrays.js b/src/js/utils/arrays.js
new file mode 100644
index 00000000..69ef242c
--- /dev/null
+++ b/src/js/utils/arrays.js
@@ -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));
+}
diff --git a/src/js/utils/browser.js b/src/js/utils/browser.js
new file mode 100644
index 00000000..d574f683
--- /dev/null
+++ b/src/js/utils/browser.js
@@ -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;
diff --git a/src/js/utils/elements.js b/src/js/utils/elements.js
new file mode 100644
index 00000000..69e4d46c
--- /dev/null
+++ b/src/js/utils/elements.js
@@ -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);
+}
diff --git a/src/js/utils/events.js b/src/js/utils/events.js
new file mode 100644
index 00000000..9f734f04
--- /dev/null
+++ b/src/js/utils/events.js
@@ -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(() => {});
+}
diff --git a/src/js/utils/fetch.js b/src/js/utils/fetch.js
new file mode 100644
index 00000000..1e506cd0
--- /dev/null
+++ b/src/js/utils/fetch.js
@@ -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);
+ }
+ });
+}
diff --git a/src/js/utils/is.js b/src/js/utils/is.js
new file mode 100644
index 00000000..cb2c07c6
--- /dev/null
+++ b/src/js/utils/is.js
@@ -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;
diff --git a/src/js/utils/loadImage.js b/src/js/utils/loadImage.js
new file mode 100644
index 00000000..8acd2496
--- /dev/null
+++ b/src/js/utils/loadImage.js
@@ -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 });
+ });
+}
diff --git a/src/js/utils/loadScript.js b/src/js/utils/loadScript.js
new file mode 100644
index 00000000..81ae36f4
--- /dev/null
+++ b/src/js/utils/loadScript.js
@@ -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,
+ });
+ });
+}
diff --git a/src/js/utils/loadSprite.js b/src/js/utils/loadSprite.js
new file mode 100644
index 00000000..dbb00cf2
--- /dev/null
+++ b/src/js/utils/loadSprite.js
@@ -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(() => {});
+ }
+}
diff --git a/src/js/utils/objects.js b/src/js/utils/objects.js
new file mode 100644
index 00000000..225bb459
--- /dev/null
+++ b/src/js/utils/objects.js
@@ -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);
+}
diff --git a/src/js/utils/strings.js b/src/js/utils/strings.js
new file mode 100644
index 00000000..c872498c
--- /dev/null
+++ b/src/js/utils/strings.js
@@ -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;
+}
diff --git a/src/js/utils/time.js b/src/js/utils/time.js
new file mode 100644
index 00000000..7c9860fd
--- /dev/null
+++ b/src/js/utils/time.js
@@ -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)}`;
+}
diff --git a/src/js/utils/urls.js b/src/js/utils/urls.js
new file mode 100644
index 00000000..3ebe622e
--- /dev/null
+++ b/src/js/utils/urls.js
@@ -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;
+}