diff options
Diffstat (limited to 'src/js')
32 files changed, 1530 insertions, 1463 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index bafcf87e..0506d1e6 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -6,7 +6,13 @@ import controls from './controls'; import i18n from './i18n'; import support from './support'; -import utils from './utils'; +import browser from './utils/browser'; +import { createElement, emptyElement, getAttributesFromSelector, insertAfter, removeElement, toggleClass } from './utils/elements'; +import { on, trigger } from './utils/events'; +import fetch from './utils/fetch'; +import is from './utils/is'; +import { getHTML } from './utils/strings'; +import { parseUrl } from './utils/urls'; const captions = { // Setup captions @@ -19,7 +25,7 @@ const captions = { // Only Vimeo and HTML5 video supported at this point if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) { // Clear menu and hide - if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) { + if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) { controls.setCaptionsMenu.call(this); } @@ -27,15 +33,12 @@ const captions = { } // Inject the container - if (!utils.is.element(this.elements.captions)) { - this.elements.captions = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.captions)); + if (!is.element(this.elements.captions)) { + this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions)); - utils.insertAfter(this.elements.captions, this.elements.wrapper); + insertAfter(this.elements.captions, this.elements.wrapper); } - // Get browser info - const browser = utils.getBrowser(); - // Fix IE captions if CORS is used // Fetch captions and inject as blobs instead (data URIs not supported!) if (browser.isIE && window.URL) { @@ -43,19 +46,18 @@ const captions = { Array.from(elements).forEach(track => { const src = track.getAttribute('src'); - const href = utils.parseUrl(src); + const url = parseUrl(src); - if (href.hostname !== window.location.href.hostname && [ + if (url !== null && url.hostname !== window.location.href.hostname && [ 'http:', 'https:', - ].includes(href.protocol)) { - utils - .fetch(src, 'blob') + ].includes(url.protocol)) { + fetch(src, 'blob') .then(blob => { track.setAttribute('src', window.URL.createObjectURL(blob)); }) .catch(() => { - utils.removeElement(track); + removeElement(track); }); } }); @@ -65,14 +67,14 @@ const captions = { let active = this.storage.get('captions'); // Otherwise fall back to the default config - if (!utils.is.boolean(active)) { + if (!is.boolean(active)) { ({ active } = this.config.captions); } // Get language from storage, fallback to config let language = this.storage.get('language') || this.config.captions.language; if (language === 'auto') { - [ language ] = (navigator.language || navigator.userLanguage).split('-'); + [language] = (navigator.language || navigator.userLanguage).split('-'); } // Set language and show if active captions.setLanguage.call(this, language, active); @@ -80,7 +82,7 @@ const captions = { // Watch changes to textTracks and update captions menu if (this.isHTML5) { const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack'; - utils.on(this.media.textTracks, trackEvents, captions.update.bind(this)); + on(this.media.textTracks, trackEvents, captions.update.bind(this)); } // Update available languages in list next tick (the event must not be triggered before the listeners) @@ -94,21 +96,19 @@ const captions = { // Handle tracks (add event listener and "pseudo"-default) if (this.isHTML5 && this.isVideo) { - tracks - .filter(track => !meta.get(track)) - .forEach(track => { - this.debug.log('Track added', track); - // Attempt to store if the original dom element was "default" - meta.set(track, { - default: track.mode === 'showing', - }); - - // Turn off native caption rendering to avoid double captions - track.mode = 'hidden'; - - // Add event listener for cue changes - utils.on(track, 'cuechange', () => captions.updateCues.call(this)); + tracks.filter(track => !meta.get(track)).forEach(track => { + this.debug.log('Track added', track); + // Attempt to store if the original dom element was "default" + meta.set(track, { + default: track.mode === 'showing', }); + + // Turn off native caption rendering to avoid double captions + track.mode = 'hidden'; + + // Add event listener for cue changes + on(track, 'cuechange', () => captions.updateCues.call(this)); + }); } const trackRemoved = !tracks.find(track => track === this.captions.currentTrackNode); @@ -120,7 +120,7 @@ const captions = { } // Enable or disable captions based on track length - utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(tracks)); + toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks)); // Update available languages in list if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) { @@ -137,7 +137,7 @@ const captions = { return; } - if (!utils.is.number(index)) { + if (!is.number(index)) { this.debug.warn('Invalid caption argument', index); return; } @@ -166,7 +166,7 @@ const captions = { } // Trigger event - utils.dispatchEvent.call(this, this.media, 'languagechange'); + trigger.call(this, this.media, 'languagechange'); } if (this.isHTML5 && this.isVideo) { @@ -181,7 +181,7 @@ const captions = { }, setLanguage(language, show = true) { - if (!utils.is.string(language)) { + if (!is.string(language)) { this.debug.warn('Invalid language argument', language); return; } @@ -202,12 +202,10 @@ const captions = { const tracks = Array.from((this.media || {}).textTracks || []); // For HTML5, use cache instead of current tracks when it exists (if captions.update is false) // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata) - return tracks - .filter(track => !this.isHTML5 || update || this.captions.meta.has(track)) - .filter(track => [ - 'captions', - 'subtitles', - ].includes(track.kind)); + return tracks.filter(track => !this.isHTML5 || update || this.captions.meta.has(track)).filter(track => [ + 'captions', + 'subtitles', + ].includes(track.kind)); }, // Get the current track for the current language @@ -222,16 +220,16 @@ const captions = { getLabel(track) { let currentTrack = track; - if (!utils.is.track(currentTrack) && support.textTracks && this.captions.active) { + if (!is.track(currentTrack) && support.textTracks && this.captions.active) { currentTrack = captions.getCurrentTrack.call(this); } - if (utils.is.track(currentTrack)) { - if (!utils.is.empty(currentTrack.label)) { + if (is.track(currentTrack)) { + if (!is.empty(currentTrack.label)) { return currentTrack.label; } - if (!utils.is.empty(currentTrack.language)) { + if (!is.empty(currentTrack.language)) { return track.language.toUpperCase(); } @@ -249,13 +247,13 @@ const captions = { return; } - if (!utils.is.element(this.elements.captions)) { + if (!is.element(this.elements.captions)) { this.debug.warn('No captions element to render to'); return; } // Only accept array or empty input - if (!utils.is.nullOrUndefined(input) && !Array.isArray(input)) { + if (!is.nullOrUndefined(input) && !Array.isArray(input)) { this.debug.warn('updateCues: Invalid input', input); return; } @@ -267,7 +265,7 @@ const captions = { const track = captions.getCurrentTrack.call(this); cues = Array.from((track || {}).activeCues || []) .map(cue => cue.getCueAsHTML()) - .map(utils.getHTML); + .map(getHTML); } // Set new caption text @@ -276,13 +274,13 @@ const captions = { if (changed) { // Empty the container and create a new child element - utils.emptyElement(this.elements.captions); - const caption = utils.createElement('span', utils.getAttributesFromSelector(this.config.selectors.caption)); + emptyElement(this.elements.captions); + const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption)); caption.innerHTML = content; this.elements.captions.appendChild(caption); // Trigger event - utils.dispatchEvent.call(this, this.media, 'cuechange'); + trigger.call(this, this.media, 'cuechange'); } }, }; diff --git a/src/js/defaults.js b/src/js/config/defaults.js index 1789b026..1789b026 100644 --- a/src/js/defaults.js +++ b/src/js/config/defaults.js diff --git a/src/js/types.js b/src/js/config/types.js index 35716c3c..13303573 100644 --- a/src/js/types.js +++ b/src/js/config/types.js @@ -13,4 +13,22 @@ export const types = { video: 'video', }; +/** + * Get provider by URL + * @param {string} url + */ +export function getProviderByUrl(url) { + // YouTube + if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/.test(url)) { + return providers.youtube; + } + + // Vimeo + if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) { + return providers.vimeo; + } + + return null; +} + export default { providers, types }; diff --git a/src/js/controls.js b/src/js/controls.js index 058e636f..cfab26bc 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -6,14 +6,17 @@ import captions from './captions'; import html5 from './html5'; import i18n from './i18n'; import support from './support'; -import utils from './utils'; - -// Sniff out the browser -const browser = utils.getBrowser(); +import { repaint, transitionEndEvent } from './utils/animation'; +import browser from './utils/browser'; +import { createElement, emptyElement, getAttributesFromSelector, getElement, getElements, hasClass, removeElement, setAttributes, toggleClass, toggleHidden, toggleState } from './utils/elements'; +import { off, on } from './utils/events'; +import is from './utils/is'; +import loadSprite from './utils/loadSprite'; +import { extend } from './utils/objects'; +import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings'; +import { formatTime, getHours } from './utils/time'; const controls = { - - // Get icon URL getIconUrl() { const url = new URL(this.config.iconUrl, window.location); @@ -29,41 +32,41 @@ const controls = { // TODO: Allow settings menus with custom controls findElements() { try { - this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper); + this.elements.controls = 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), - fastForward: utils.getElement.call(this, this.config.selectors.buttons.fastForward), - 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), + 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 = utils.getElement.call(this, this.config.selectors.progress); + this.elements.progress = 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), + seek: getElement.call(this, this.config.selectors.inputs.seek), + volume: getElement.call(this, this.config.selectors.inputs.volume), }; // Display this.elements.display = { - buffer: utils.getElement.call(this, this.config.selectors.display.buffer), - currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime), - duration: utils.getElement.call(this, this.config.selectors.display.duration), + 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 (utils.is.element(this.elements.progress)) { + if (is.element(this.elements.progress)) { this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`); } @@ -87,9 +90,9 @@ const controls = { // Create <svg> const icon = document.createElementNS(namespace, 'svg'); - utils.setAttributes( + setAttributes( icon, - utils.extend(attributes, { + extend(attributes, { role: 'presentation', focusable: 'false', }), @@ -138,21 +141,21 @@ const controls = { attributes.class = this.config.classNames.hidden; } - return utils.createElement('span', attributes, text); + return createElement('span', attributes, text); }, // Create a badge createBadge(text) { - if (utils.is.empty(text)) { + if (is.empty(text)) { return null; } - const badge = utils.createElement('span', { + const badge = createElement('span', { class: this.config.classNames.menu.value, }); badge.appendChild( - utils.createElement( + createElement( 'span', { class: this.config.classNames.menu.badge, @@ -166,9 +169,9 @@ const controls = { // Create a <button> createButton(buttonType, attr) { - const button = utils.createElement('button'); + const button = createElement('button'); const attributes = Object.assign({}, attr); - let type = utils.toCamelCase(buttonType); + let type = toCamelCase(buttonType); let toggle = false; let label; @@ -252,13 +255,13 @@ const controls = { } // Merge attributes - utils.extend(attributes, utils.getAttributesFromSelector(this.config.selectors.buttons[type], attributes)); + extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes)); - utils.setAttributes(button, attributes); + setAttributes(button, attributes); // We have multiple play buttons if (type === 'play') { - if (!utils.is.array(this.elements.buttons[type])) { + if (!is.array(this.elements.buttons[type])) { this.elements.buttons[type] = []; } @@ -273,7 +276,7 @@ const controls = { // Create an <input type='range'> createRange(type, attributes) { // Seek label - const label = utils.createElement( + const label = createElement( 'label', { for: attributes.id, @@ -284,10 +287,10 @@ const controls = { ); // Seek input - const input = utils.createElement( + const input = createElement( 'input', - utils.extend( - utils.getAttributesFromSelector(this.config.selectors.inputs[type]), + extend( + getAttributesFromSelector(this.config.selectors.inputs[type]), { type: 'range', min: 0, @@ -319,10 +322,10 @@ const controls = { // Create a <progress> createProgress(type, attributes) { - const progress = utils.createElement( + const progress = createElement( 'progress', - utils.extend( - utils.getAttributesFromSelector(this.config.selectors.display[type]), + extend( + getAttributesFromSelector(this.config.selectors.display[type]), { min: 0, max: 100, @@ -336,7 +339,7 @@ const controls = { // Create the label inside if (type !== 'volume') { - progress.appendChild(utils.createElement('span', null, '0')); + progress.appendChild(createElement('span', null, '0')); let suffix = ''; switch (type) { @@ -362,12 +365,16 @@ const controls = { // Create time display createTime(type) { - const attributes = utils.getAttributesFromSelector(this.config.selectors.display[type]); + const attributes = getAttributesFromSelector(this.config.selectors.display[type]); - const container = utils.createElement('div', utils.extend(attributes, { - class: `plyr__time ${attributes.class}`, - 'aria-label': i18n.get(type, this.config), - }), '00:00'); + const container = createElement( + 'div', + extend(attributes, { + class: `plyr__time ${attributes.class}`, + 'aria-label': i18n.get(type, this.config), + }), + '00:00', + ); // Reference for updates this.elements.display[type] = container; @@ -376,16 +383,16 @@ const controls = { }, // Create a settings menu item - createMenuItem({value, list, type, title, badge = null, checked = false}) { - const item = utils.createElement('li'); + createMenuItem({ value, list, type, title, badge = null, checked = false }) { + const item = createElement('li'); - const label = utils.createElement('label', { + const label = createElement('label', { class: this.config.classNames.control, }); - const radio = utils.createElement( + const radio = createElement( 'input', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), { + extend(getAttributesFromSelector(this.config.selectors.inputs[type]), { type: 'radio', name: `plyr-${type}`, value, @@ -394,13 +401,13 @@ const controls = { }), ); - const faux = utils.createElement('span', { hidden: '' }); + const faux = createElement('span', { hidden: '' }); label.appendChild(radio); label.appendChild(faux); label.insertAdjacentHTML('beforeend', title); - if (utils.is.element(badge)) { + if (is.element(badge)) { label.appendChild(badge); } @@ -411,15 +418,15 @@ const controls = { // Update the displayed time updateTimeDisplay(target = null, time = 0, inverted = false) { // Bail if there's no element to display or the value isn't a number - if (!utils.is.element(target) || !utils.is.number(time)) { + if (!is.element(target) || !is.number(time)) { return; } // Always display hours if duration is over an hour - const forceHours = utils.getHours(this.duration) > 0; + const forceHours = getHours(this.duration) > 0; // eslint-disable-next-line no-param-reassign - target.innerText = utils.formatTime(time, forceHours, inverted); + target.innerText = formatTime(time, forceHours, inverted); }, // Update volume UI and storage @@ -429,19 +436,19 @@ const controls = { } // Update range - if (utils.is.element(this.elements.inputs.volume)) { + if (is.element(this.elements.inputs.volume)) { controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); } // Update mute state - if (utils.is.element(this.elements.buttons.mute)) { - utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); + if (is.element(this.elements.buttons.mute)) { + toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); } }, // Update seek value and lower fill setRange(target, value = 0) { - if (!utils.is.element(target)) { + if (!is.element(target)) { return; } @@ -454,23 +461,23 @@ const controls = { // Update <progress> elements updateProgress(event) { - if (!this.supported.ui || !utils.is.event(event)) { + if (!this.supported.ui || !is.event(event)) { return; } let value = 0; const setProgress = (target, input) => { - const value = utils.is.number(input) ? input : 0; - const progress = utils.is.element(target) ? target : this.elements.display.buffer; + const value = is.number(input) ? input : 0; + const progress = is.element(target) ? target : this.elements.display.buffer; // Update value and label - if (utils.is.element(progress)) { + if (is.element(progress)) { progress.value = value; // Update text label inside const label = progress.getElementsByTagName('span')[0]; - if (utils.is.element(label)) { + if (is.element(label)) { label.childNodes[0].nodeValue = value; } } @@ -482,7 +489,7 @@ const controls = { case 'timeupdate': case 'seeking': case 'seeked': - value = utils.getPercentage(this.currentTime, this.duration); + value = getPercentage(this.currentTime, this.duration); // Set seek range value only if it's a 'natural' time event if (event.type === 'timeupdate') { @@ -507,10 +514,10 @@ const controls = { // Webkit polyfill for lower fill range updateRangeFill(target) { // Get range from event if event passed - const range = utils.is.event(target) ? target.target : target; + const range = is.event(target) ? target.target : target; // Needs to be a valid <input type='range'> - if (!utils.is.element(range) || range.getAttribute('type') !== 'range') { + if (!is.element(range) || range.getAttribute('type') !== 'range') { return; } @@ -529,12 +536,7 @@ const controls = { // Update hover tooltip for seeking updateSeekTooltip(event) { // Bail if setting not true - if ( - !this.config.tooltips.seek || - !utils.is.element(this.elements.inputs.seek) || - !utils.is.element(this.elements.display.seekTooltip) || - this.duration === 0 - ) { + if (!this.config.tooltips.seek || !is.element(this.elements.inputs.seek) || !is.element(this.elements.display.seekTooltip) || this.duration === 0) { return; } @@ -544,7 +546,7 @@ const controls = { const visible = `${this.config.classNames.tooltip}--visible`; const toggle = toggle => { - utils.toggleClass(this.elements.display.seekTooltip, visible, toggle); + toggleClass(this.elements.display.seekTooltip, visible, toggle); }; // Hide on touch @@ -554,9 +556,9 @@ const controls = { } // Determine percentage, if already visible - if (utils.is.event(event)) { + if (is.event(event)) { percent = 100 / clientRect.width * (event.pageX - clientRect.left); - } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) { + } else if (hasClass(this.elements.display.seekTooltip, visible)) { percent = parseFloat(this.elements.display.seekTooltip.style.left, 10); } else { return; @@ -577,7 +579,7 @@ const controls = { // Show/hide the tooltip // If the event is a moues in/out and percentage is inside bounds - if (utils.is.event(event) && [ + if (is.event(event) && [ 'mouseenter', 'mouseleave', ].includes(event.type)) { @@ -588,7 +590,7 @@ const controls = { // Handle time change event timeUpdate(event) { // Only invert if only one time element is displayed and used for both duration and currentTime - const invert = !utils.is.element(this.elements.display.duration) && this.config.invertTime; + const invert = !is.element(this.elements.display.duration) && this.config.invertTime; // Duration controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert); @@ -610,7 +612,7 @@ const controls = { } // If there's a spot to display duration - const hasDuration = utils.is.element(this.elements.display.duration); + const hasDuration = is.element(this.elements.display.duration); // If there's only one time display, display duration there if (!hasDuration && this.config.displayDuration && this.paused) { @@ -628,14 +630,14 @@ const controls = { // Hide/show a tab toggleTab(setting, toggle) { - utils.toggleHidden(this.elements.settings.tabs[setting], !toggle); + toggleHidden(this.elements.settings.tabs[setting], !toggle); }, // Set the quality menu // TODO: Vimeo support setQualityMenu(options) { // Menu required - if (!utils.is.element(this.elements.settings.panes.quality)) { + if (!is.element(this.elements.settings.panes.quality)) { return; } @@ -643,12 +645,12 @@ const controls = { const list = this.elements.settings.panes.quality.querySelector('ul'); // Set options if passed and filter based on config - if (utils.is.array(options)) { + if (is.array(options)) { this.options.quality = options.filter(quality => this.config.quality.options.includes(quality)); } // Toggle the pane and tab - const toggle = !utils.is.empty(this.options.quality) && this.options.quality.length > 1; + const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1; controls.toggleTab.call(this, type, toggle); // Check if we need to toggle the parent @@ -660,7 +662,7 @@ const controls = { } // Empty the menu - utils.emptyElement(list); + emptyElement(list); // Get the badge HTML for HD, 4K etc const getBadge = quality => { @@ -699,7 +701,7 @@ const controls = { return value === 1 ? i18n.get('normal', this.config) : `${value}×`; case 'quality': - if (utils.is.number(value)) { + if (is.number(value)) { const label = i18n.get(`qualityLabel.${value}`, this.config); if (!label.length) { @@ -709,7 +711,7 @@ const controls = { return label; } - return utils.toTitleCase(value); + return toTitleCase(value); case 'captions': return captions.getLabel.call(this); @@ -731,15 +733,15 @@ const controls = { break; default: - value = !utils.is.empty(input) ? input : this[setting]; + value = !is.empty(input) ? input : this[setting]; // Get default - if (utils.is.empty(value)) { + if (is.empty(value)) { value = this.config[setting].default; } // Unsupported value - if (!utils.is.empty(this.options[setting]) && !this.options[setting].includes(value)) { + if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) { this.debug.warn(`Unsupported value of '${value}' for ${setting}`); return; } @@ -754,12 +756,12 @@ const controls = { } // Get the list if we need to - if (!utils.is.element(list)) { + if (!is.element(list)) { list = pane && pane.querySelector('ul'); } // If there's no list it means it's not been rendered... - if (!utils.is.element(list)) { + if (!is.element(list)) { return; } @@ -770,7 +772,7 @@ const controls = { // Find the radio option and check it const target = list && list.querySelector(`input[value="${value}"]`); - if (utils.is.element(target)) { + if (is.element(target)) { target.checked = true; } }, @@ -778,7 +780,7 @@ const controls = { // Set the looping options /* setLoopMenu() { // Menu required - if (!utils.is.element(this.elements.settings.panes.loop)) { + if (!is.element(this.elements.settings.panes.loop)) { return; } @@ -786,22 +788,22 @@ const controls = { const list = this.elements.settings.panes.loop.querySelector('ul'); // Show the pane and tab - utils.toggleHidden(this.elements.settings.tabs.loop, false); - utils.toggleHidden(this.elements.settings.panes.loop, false); + toggleHidden(this.elements.settings.tabs.loop, false); + toggleHidden(this.elements.settings.panes.loop, false); // Toggle the pane and tab - const toggle = !utils.is.empty(this.loop.options); + const toggle = !is.empty(this.loop.options); controls.toggleTab.call(this, 'loop', toggle); // Empty the menu - utils.emptyElement(list); + emptyElement(list); options.forEach(option => { - const item = utils.createElement('li'); + const item = createElement('li'); - const button = utils.createElement( + const button = createElement( 'button', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.loop), { + extend(getAttributesFromSelector(this.config.selectors.buttons.loop), { type: 'button', class: this.config.classNames.control, 'data-plyr-loop-action': option, @@ -833,7 +835,7 @@ const controls = { controls.toggleTab.call(this, type, tracks.length); // Empty the menu - utils.emptyElement(list); + emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); @@ -876,14 +878,14 @@ const controls = { } // Menu required - if (!utils.is.element(this.elements.settings.panes.speed)) { + if (!is.element(this.elements.settings.panes.speed)) { return; } const type = 'speed'; // Set the speed options - if (utils.is.array(options)) { + if (is.array(options)) { this.options.speed = options; } else if (this.isHTML5 || this.isVimeo) { this.options.speed = [ @@ -901,7 +903,7 @@ const controls = { this.options.speed = this.options.speed.filter(speed => this.config.speed.options.includes(speed)); // Toggle the pane and tab - const toggle = !utils.is.empty(this.options.speed) && this.options.speed.length > 1; + const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1; controls.toggleTab.call(this, type, toggle); // Check if we need to toggle the parent @@ -916,7 +918,7 @@ const controls = { const list = this.elements.settings.panes.speed.querySelector('ul'); // Empty the menu - utils.emptyElement(list); + emptyElement(list); // Create items this.options.speed.forEach(speed => { @@ -934,9 +936,9 @@ const controls = { // Check if we need to hide/show the settings menu checkMenu() { const { tabs } = this.elements.settings; - const visible = !utils.is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden); + const visible = !is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden); - utils.toggleHidden(this.elements.settings.menu, !visible); + toggleHidden(this.elements.settings.menu, !visible); }, // Show/hide menu @@ -945,14 +947,14 @@ const controls = { const button = this.elements.buttons.settings; // Menu and button are required - if (!utils.is.element(form) || !utils.is.element(button)) { + if (!is.element(form) || !is.element(button)) { return; } - const show = utils.is.boolean(event) ? event : utils.is.element(form) && form.hasAttribute('hidden'); + const show = is.boolean(event) ? event : is.element(form) && form.hasAttribute('hidden'); - if (utils.is.event(event)) { - const isMenuItem = utils.is.element(form) && form.contains(event.target); + if (is.event(event)) { + const isMenuItem = is.element(form) && form.contains(event.target); const isButton = event.target === this.elements.buttons.settings; // If the click was inside the form or if the click @@ -969,13 +971,13 @@ const controls = { } // Set form and button attributes - if (utils.is.element(button)) { + if (is.element(button)) { button.setAttribute('aria-expanded', show); } - if (utils.is.element(form)) { - utils.toggleHidden(form, !show); - utils.toggleClass(this.elements.container, this.config.classNames.menu.open, show); + if (is.element(form)) { + toggleHidden(form, !show); + toggleClass(this.elements.container, this.config.classNames.menu.open, show); if (show) { form.removeAttribute('tabindex'); @@ -1006,7 +1008,7 @@ const controls = { const height = clone.scrollHeight; // Remove from the DOM - utils.removeElement(clone); + removeElement(clone); return { width, @@ -1020,7 +1022,7 @@ const controls = { const pane = document.getElementById(target); // Nothing to show, bail - if (!utils.is.element(pane)) { + if (!is.element(pane)) { return; } @@ -1064,11 +1066,11 @@ const controls = { container.style.height = ''; // Only listen once - utils.off(container, utils.transitionEndEvent, restore); + off(container, transitionEndEvent, restore); }; // Listen for the transition finishing and restore auto height/width - utils.on(container, utils.transitionEndEvent, restore); + on(container, transitionEndEvent, restore); // Set dimensions to target container.style.width = `${size.width}px`; @@ -1076,13 +1078,13 @@ const controls = { } // Set attributes on current tab - utils.toggleHidden(current, true); + toggleHidden(current, true); current.setAttribute('tabindex', -1); // Set attributes on target - utils.toggleHidden(pane, false); + toggleHidden(pane, false); - const tabs = utils.getElements.call(this, `[aria-controls="${target}"]`); + const tabs = getElements.call(this, `[aria-controls="${target}"]`); Array.from(tabs).forEach(tab => { tab.setAttribute('aria-expanded', true); }); @@ -1096,12 +1098,12 @@ const controls = { // TODO: Set order based on order in the config.controls array? create(data) { // Do nothing if we want no controls - if (utils.is.empty(this.config.controls)) { + if (is.empty(this.config.controls)) { return null; } // Create the container - const container = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.controls.wrapper)); + const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); // Restart button if (this.config.controls.includes('restart')) { @@ -1125,7 +1127,7 @@ const controls = { // Progress if (this.config.controls.includes('progress')) { - const progress = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.progress)); + const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); // Seek range slider const seek = controls.createRange.call(this, 'seek', { @@ -1141,7 +1143,7 @@ const controls = { // Seek tooltip if (this.config.tooltips.seek) { - const tooltip = utils.createElement( + const tooltip = createElement( 'span', { class: this.config.classNames.tooltip, @@ -1174,7 +1176,7 @@ const controls = { // Volume range control if (this.config.controls.includes('volume')) { - const volume = utils.createElement('div', { + const volume = createElement('div', { class: 'plyr__volume', }); @@ -1189,7 +1191,7 @@ const controls = { const range = controls.createRange.call( this, 'volume', - utils.extend(attributes, { + extend(attributes, { id: `plyr-volume-${data.id}`, }), ); @@ -1207,8 +1209,8 @@ const controls = { } // Settings button / menu - if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) { - const menu = utils.createElement('div', { + if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) { + const menu = createElement('div', { class: 'plyr__menu', hidden: '', }); @@ -1222,7 +1224,7 @@ const controls = { }), ); - const form = utils.createElement('form', { + const form = createElement('form', { class: 'plyr__menu__container', id: `plyr-settings-${data.id}`, hidden: '', @@ -1231,29 +1233,29 @@ const controls = { tabindex: -1, }); - const inner = utils.createElement('div'); + const inner = createElement('div'); - const home = utils.createElement('div', { + const home = createElement('div', { id: `plyr-settings-${data.id}-home`, 'aria-labelled-by': `plyr-settings-toggle-${data.id}`, role: 'tabpanel', }); // Create the tab list - const tabs = utils.createElement('ul', { + const tabs = createElement('ul', { role: 'tablist', }); // Build the tabs this.config.settings.forEach(type => { - const tab = utils.createElement('li', { + const tab = createElement('li', { role: 'tab', hidden: '', }); - const button = utils.createElement( + const button = createElement( 'button', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.settings), { + extend(getAttributesFromSelector(this.config.selectors.buttons.settings), { type: 'button', class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`, id: `plyr-settings-${data.id}-${type}-tab`, @@ -1264,7 +1266,7 @@ const controls = { i18n.get(type, this.config), ); - const value = utils.createElement('span', { + const value = createElement('span', { class: this.config.classNames.menu.value, }); @@ -1283,7 +1285,7 @@ const controls = { // Build the panes this.config.settings.forEach(type => { - const pane = utils.createElement('div', { + const pane = createElement('div', { id: `plyr-settings-${data.id}-${type}`, hidden: '', 'aria-labelled-by': `plyr-settings-${data.id}-${type}-tab`, @@ -1291,7 +1293,7 @@ const controls = { tabindex: -1, }); - const back = utils.createElement( + const back = createElement( 'button', { type: 'button', @@ -1305,7 +1307,7 @@ const controls = { pane.appendChild(back); - const options = utils.createElement('ul'); + const options = createElement('ul'); pane.appendChild(options); inner.appendChild(pane); @@ -1360,7 +1362,7 @@ const controls = { // Only load external sprite using AJAX if (icon.cors) { - utils.loadSprite(icon.url, 'sprite-plyr'); + loadSprite(icon.url, 'sprite-plyr'); } } @@ -1379,10 +1381,10 @@ const controls = { }; let update = true; - if (utils.is.string(this.config.controls) || utils.is.element(this.config.controls)) { + if (is.string(this.config.controls) || is.element(this.config.controls)) { // String or HTMLElement passed as the option container = this.config.controls; - } else if (utils.is.function(this.config.controls)) { + } else if (is.function(this.config.controls)) { // A custom function to build controls // The function can return a HTMLElement or String container = this.config.controls.call(this, props); @@ -1408,7 +1410,7 @@ const controls = { key, value, ]) => { - result = utils.replaceAll(result, `{${key}}`, value); + result = replaceAll(result, `{${key}}`, value); }); return result; @@ -1416,9 +1418,9 @@ const controls = { // Update markup if (update) { - if (utils.is.string(this.config.controls)) { + if (is.string(this.config.controls)) { container = replace(container); - } else if (utils.is.element(container)) { + } else if (is.element(container)) { container.innerHTML = replace(container.innerHTML); } } @@ -1427,35 +1429,35 @@ const controls = { let target; // Inject to custom location - if (utils.is.string(this.config.selectors.controls.container)) { + if (is.string(this.config.selectors.controls.container)) { target = document.querySelector(this.config.selectors.controls.container); } // Inject into the container by default - if (!utils.is.element(target)) { + if (!is.element(target)) { target = this.elements.container; } // Inject controls HTML - if (utils.is.element(container)) { + if (is.element(container)) { target.appendChild(container); } else if (container) { target.insertAdjacentHTML('beforeend', container); } // Find the elements if need be - if (!utils.is.element(this.elements.controls)) { + if (!is.element(this.elements.controls)) { controls.findElements.call(this); } // Edge sometimes doesn't finish the paint so force a redraw if (window.navigator.userAgent.includes('Edge')) { - utils.repaint(target); + repaint(target); } // Setup tooltips if (this.config.tooltips.controls) { - const labels = utils.getElements.call( + const labels = getElements.call( this, [ this.config.selectors.controls.wrapper, @@ -1467,8 +1469,8 @@ const controls = { ); Array.from(labels).forEach(label => { - utils.toggleClass(label, this.config.classNames.hidden, false); - utils.toggleClass(label, this.config.classNames.tooltip, true); + toggleClass(label, this.config.classNames.hidden, false); + toggleClass(label, this.config.classNames.tooltip, true); label.setAttribute('role', 'tooltip'); }); } diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js index 000ba706..180853c5 100644 --- a/src/js/fullscreen.js +++ b/src/js/fullscreen.js @@ -3,9 +3,10 @@ // https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API#prefixing // ========================================================================== -import utils from './utils'; - -const browser = utils.getBrowser(); +import browser from './utils/browser'; +import { hasClass, toggleClass, toggleState, trapFocus } from './utils/elements'; +import { on, trigger } from './utils/events'; +import is from './utils/is'; function onChange() { if (!this.enabled) { @@ -14,16 +15,16 @@ function onChange() { // Update toggle button const button = this.player.elements.buttons.fullscreen; - if (utils.is.element(button)) { - utils.toggleState(button, this.active); + if (is.element(button)) { + toggleState(button, this.active); } // Trigger an event - utils.dispatchEvent.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true); + trigger.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true); // Trap focus in container if (!browser.isIos) { - utils.trapFocus.call(this.player, this.target, this.active); + trapFocus.call(this.player, this.target, this.active); } } @@ -42,7 +43,7 @@ function toggleFallback(toggle = false) { document.body.style.overflow = toggle ? 'hidden' : ''; // Toggle class hook - utils.toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle); + toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle); // Toggle button and fire events onChange.call(this); @@ -62,15 +63,15 @@ class Fullscreen { // Register event listeners // Handle event (incase user presses escape etc) - utils.on(document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => { + on(document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => { // TODO: Filter for target?? onChange.call(this); }); // Fullscreen toggle on double click - utils.on(this.player.elements.container, 'dblclick', event => { + on(this.player.elements.container, 'dblclick', event => { // Ignore double click in controls - if (utils.is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) { + if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) { return; } @@ -89,7 +90,7 @@ class Fullscreen { // Get the prefix for handlers static get prefix() { // No prefix - if (utils.is.function(document.exitFullscreen)) { + if (is.function(document.exitFullscreen)) { return ''; } @@ -102,7 +103,7 @@ class Fullscreen { ]; prefixes.some(pre => { - if (utils.is.function(document[`${pre}ExitFullscreen`]) || utils.is.function(document[`${pre}CancelFullScreen`])) { + if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) { value = pre; return true; } @@ -135,7 +136,7 @@ class Fullscreen { // Fallback using classname if (!Fullscreen.native) { - return utils.hasClass(this.target, this.player.config.classNames.fullscreen.fallback); + return hasClass(this.target, this.player.config.classNames.fullscreen.fallback); } const element = !this.prefix ? document.fullscreenElement : document[`${this.prefix}${this.property}Element`]; @@ -157,7 +158,7 @@ class Fullscreen { } // Add styling hook to show button - utils.toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled); + toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled); } // Make an element fullscreen @@ -175,7 +176,7 @@ class Fullscreen { toggleFallback.call(this, true); } else if (!this.prefix) { this.target.requestFullscreen(); - } else if (!utils.is.empty(this.prefix)) { + } else if (!is.empty(this.prefix)) { this.target[`${this.prefix}Request${this.property}`](); } } @@ -194,7 +195,7 @@ class Fullscreen { toggleFallback.call(this, false); } else if (!this.prefix) { (document.cancelFullScreen || document.exitFullscreen).call(document); - } else if (!utils.is.empty(this.prefix)) { + } else if (!is.empty(this.prefix)) { const action = this.prefix === 'moz' ? 'Cancel' : 'Exit'; document[`${this.prefix}${action}${this.property}`](); } diff --git a/src/js/html5.js b/src/js/html5.js index 63596cfc..d13e6aa6 100644 --- a/src/js/html5.js +++ b/src/js/html5.js @@ -3,7 +3,10 @@ // ========================================================================== import support from './support'; -import utils from './utils'; +import { dedupe } from './utils/arrays'; +import { removeElement } from './utils/elements'; +import { trigger } from './utils/events'; +import is from './utils/is'; const html5 = { getSources() { @@ -23,20 +26,20 @@ const html5 = { // Get sources const sources = html5.getSources.call(this); - if (utils.is.empty(sources)) { + if (is.empty(sources)) { return null; } // Get <source> with size attribute - const sizes = Array.from(sources).filter(source => !utils.is.empty(source.getAttribute('size'))); + const sizes = Array.from(sources).filter(source => !is.empty(source.getAttribute('size'))); // If none, bail - if (utils.is.empty(sizes)) { + if (is.empty(sizes)) { return null; } // Reduce to unique list - return utils.dedupe(sizes.map(source => Number(source.getAttribute('size')))); + return dedupe(sizes.map(source => Number(source.getAttribute('size')))); }, extend() { @@ -52,13 +55,13 @@ const html5 = { // Get sources const sources = html5.getSources.call(player); - if (utils.is.empty(sources)) { + if (is.empty(sources)) { return null; } const matches = Array.from(sources).filter(source => source.getAttribute('src') === player.source); - if (utils.is.empty(matches)) { + if (is.empty(matches)) { return null; } @@ -68,7 +71,7 @@ const html5 = { // Get sources const sources = html5.getSources.call(player); - if (utils.is.empty(sources)) { + if (is.empty(sources)) { return; } @@ -76,7 +79,7 @@ const html5 = { const matches = Array.from(sources).filter(source => Number(source.getAttribute('size')) === input); // No matches for requested size - if (utils.is.empty(matches)) { + if (is.empty(matches)) { return; } @@ -84,12 +87,12 @@ const html5 = { const supported = matches.filter(source => support.mime.call(player, source.getAttribute('type'))); // No supported sources - if (utils.is.empty(supported)) { + if (is.empty(supported)) { return; } // Trigger change event - utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, { + trigger.call(player, player.media, 'qualityrequested', false, { quality: input, }); @@ -115,7 +118,7 @@ const html5 = { } // Trigger change event - utils.dispatchEvent.call(player, player.media, 'qualitychange', false, { + trigger.call(player, player.media, 'qualitychange', false, { quality: input, }); }, @@ -130,7 +133,7 @@ const html5 = { } // Remove child sources - utils.removeElement(html5.getSources()); + removeElement(html5.getSources()); // Set blank video src attribute // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error diff --git a/src/js/i18n.js b/src/js/i18n.js index 62e5bdb0..f1108dae 100644 --- a/src/js/i18n.js +++ b/src/js/i18n.js @@ -2,17 +2,19 @@ // Plyr internationalization // ========================================================================== -import utils from './utils'; +import is from './utils/is'; +import { getDeep } from './utils/objects'; +import { replaceAll } from './utils/strings'; const i18n = { get(key = '', config = {}) { - if (utils.is.empty(key) || utils.is.empty(config)) { + if (is.empty(key) || is.empty(config)) { return ''; } - let string = utils.getDeep(config.i18n, key); + let string = getDeep(config.i18n, key); - if (utils.is.empty(string)) { + if (is.empty(string)) { return ''; } @@ -25,7 +27,7 @@ const i18n = { key, value, ]) => { - string = utils.replaceAll(string, key, value); + string = replaceAll(string, key, value); }); return string; diff --git a/src/js/listeners.js b/src/js/listeners.js index c391ea4c..b3cc3779 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -4,10 +4,10 @@ import controls from './controls'; import ui from './ui'; -import utils from './utils'; - -// Sniff out the browser -const browser = utils.getBrowser(); +import browser from './utils/browser'; +import { getElement, getElements, getFocusElement, matches, toggleClass, toggleHidden } from './utils/elements'; +import { off, on, toggleListener, trigger } from './utils/events'; +import is from './utils/is'; class Listeners { constructor(player) { @@ -32,7 +32,7 @@ class Listeners { // If the event is bubbled from the media element // Firefox doesn't get the keycode for whatever reason - if (!utils.is.number(code)) { + if (!is.number(code)) { return; } @@ -73,10 +73,10 @@ class Listeners { // Check focused element // and if the focused element is not editable (e.g. text input) // and any that accept key input http://webaim.org/techniques/keyboard/ - const focused = utils.getFocusElement(); - if (utils.is.element(focused) && ( + const focused = getFocusElement(); + if (is.element(focused) && ( focused !== this.player.elements.inputs.seek && - utils.matches(focused, this.player.config.selectors.editable)) + matches(focused, this.player.config.selectors.editable)) ) { return; } @@ -195,41 +195,41 @@ class Listeners { this.player.touch = true; // Add touch class - utils.toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true); + toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true); // Clean up - utils.off(document.body, 'touchstart', this.firstTouch); + off(document.body, 'touchstart', this.firstTouch); } // Global window & document listeners global(toggle = true) { // Keyboard shortcuts if (this.player.config.keyboard.global) { - utils.toggleListener(window, 'keydown keyup', this.handleKey, toggle, false); + toggleListener(window, 'keydown keyup', this.handleKey, toggle, false); } // Click anywhere closes menu - utils.toggleListener(document.body, 'click', this.toggleMenu, toggle); + toggleListener(document.body, 'click', this.toggleMenu, toggle); // Detect touch by events - utils.on(document.body, 'touchstart', this.firstTouch); + on(document.body, 'touchstart', this.firstTouch); } // Container listeners container() { // Keyboard shortcuts if (!this.player.config.keyboard.global && this.player.config.keyboard.focused) { - utils.on(this.player.elements.container, 'keydown keyup', this.handleKey, false); + on(this.player.elements.container, 'keydown keyup', this.handleKey, false); } // Detect tab focus // Remove class on blur/focusout - utils.on(this.player.elements.container, 'focusout', event => { - utils.toggleClass(event.target, this.player.config.classNames.tabFocus, false); + on(this.player.elements.container, 'focusout', event => { + toggleClass(event.target, this.player.config.classNames.tabFocus, false); }); // Add classname to tabbed elements - utils.on(this.player.elements.container, 'keydown', event => { + on(this.player.elements.container, 'keydown', event => { if (event.keyCode !== 9) { return; } @@ -237,12 +237,12 @@ class Listeners { // Delay the adding of classname until the focus has changed // This event fires before the focusin event setTimeout(() => { - utils.toggleClass(utils.getFocusElement(), this.player.config.classNames.tabFocus, true); + toggleClass(getFocusElement(), this.player.config.classNames.tabFocus, true); }, 0); }); // Toggle controls on mouse events and entering fullscreen - utils.on(this.player.elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => { + on(this.player.elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => { const { controls } = this.player.elements; // Remove button states for fullscreen @@ -276,20 +276,20 @@ class Listeners { // Listen for media events media() { // Time change on media - utils.on(this.player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(this.player, event)); + on(this.player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(this.player, event)); // Display duration - utils.on(this.player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(this.player, event)); + on(this.player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(this.player, event)); // Check for audio tracks on load // We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point - utils.on(this.player.media, 'loadeddata', () => { - utils.toggleHidden(this.player.elements.volume, !this.player.hasAudio); - utils.toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio); + on(this.player.media, 'loadeddata canplay', () => { + toggleHidden(this.player.elements.volume, !this.player.hasAudio); + toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio); }); // Handle the media finishing - utils.on(this.player.media, 'ended', () => { + on(this.player.media, 'ended', () => { // Show poster on end if (this.player.isHTML5 && this.player.isVideo && this.player.config.resetOnEnd) { // Restart @@ -298,20 +298,20 @@ class Listeners { }); // Check for buffer progress - utils.on(this.player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(this.player, event)); + on(this.player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(this.player, event)); // Handle volume changes - utils.on(this.player.media, 'volumechange', event => controls.updateVolume.call(this.player, event)); + on(this.player.media, 'volumechange', event => controls.updateVolume.call(this.player, event)); // Handle play/pause - utils.on(this.player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(this.player, event)); + on(this.player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(this.player, event)); // Loading state - utils.on(this.player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(this.player, event)); + on(this.player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(this.player, event)); // If autoplay, then load advertisement if required // TODO: Show some sort of loading state while the ad manager loads else there's a delay before ad shows - utils.on(this.player.media, 'playing', () => { + on(this.player.media, 'playing', () => { if (!this.player.ads) { return; } @@ -326,15 +326,15 @@ class Listeners { // Click video if (this.player.supported.ui && this.player.config.clickToPlay && !this.player.isAudio) { // Re-fetch the wrapper - const wrapper = utils.getElement.call(this.player, `.${this.player.config.classNames.video}`); + const wrapper = getElement.call(this.player, `.${this.player.config.classNames.video}`); // Bail if there's no wrapper (this should never happen) - if (!utils.is.element(wrapper)) { + if (!is.element(wrapper)) { return; } // On click play, pause ore restart - utils.on(wrapper, 'click', () => { + on(wrapper, 'click', () => { // Touch devices will just show controls (if we're hiding controls) if (this.player.config.hideControls && this.player.touch && !this.player.paused) { return; @@ -353,7 +353,7 @@ class Listeners { // Disable right click if (this.player.supported.ui && this.player.config.disableContextMenu) { - utils.on( + on( this.player.elements.wrapper, 'contextmenu', event => { @@ -364,13 +364,13 @@ class Listeners { } // Volume change - utils.on(this.player.media, 'volumechange', () => { + on(this.player.media, 'volumechange', () => { // Save to storage this.player.storage.set({ volume: this.player.volume, muted: this.player.muted }); }); // Speed change - utils.on(this.player.media, 'ratechange', () => { + on(this.player.media, 'ratechange', () => { // Update UI controls.updateSetting.call(this.player, 'speed'); @@ -379,19 +379,19 @@ class Listeners { }); // Quality request - utils.on(this.player.media, 'qualityrequested', event => { + on(this.player.media, 'qualityrequested', event => { // Save to storage this.player.storage.set({ quality: event.detail.quality }); }); // Quality change - utils.on(this.player.media, 'qualitychange', event => { + on(this.player.media, 'qualitychange', event => { // Update UI controls.updateSetting.call(this.player, 'quality', null, event.detail.quality); }); // Caption language change - utils.on(this.player.media, 'languagechange', () => { + on(this.player.media, 'languagechange', () => { // Update UI controls.updateSetting.call(this.player, 'captions'); @@ -400,7 +400,7 @@ class Listeners { }); // Captions toggle - utils.on(this.player.media, 'captionsenabled captionsdisabled', () => { + on(this.player.media, 'captionsenabled captionsdisabled', () => { // Update UI controls.updateSetting.call(this.player, 'captions'); @@ -410,7 +410,7 @@ class Listeners { // Proxy events to container // Bubble up key events for Edge - utils.on(this.player.media, this.player.config.events.concat([ + on(this.player.media, this.player.config.events.concat([ 'keyup', 'keydown', ]).join(' '), event => { @@ -421,7 +421,7 @@ class Listeners { detail = this.player.media.error; } - utils.dispatchEvent.call(this.player, this.player.elements.container, event.type, true, detail); + trigger.call(this.player, this.player.elements.container, event.type, true, detail); }); } @@ -433,7 +433,7 @@ class Listeners { // Run default and custom handlers const proxy = (event, defaultHandler, customHandlerKey) => { const customHandler = this.player.config.listeners[customHandlerKey]; - const hasCustomHandler = utils.is.function(customHandler); + const hasCustomHandler = is.function(customHandler); let returned = true; // Execute custom handler @@ -442,33 +442,33 @@ class Listeners { } // Only call default handler if not prevented in custom handler - if (returned && utils.is.function(defaultHandler)) { + if (returned && is.function(defaultHandler)) { defaultHandler.call(this.player, event); } }; // Trigger custom and default handlers - const on = (element, type, defaultHandler, customHandlerKey, passive = true) => { + const bind = (element, type, defaultHandler, customHandlerKey, passive = true) => { const customHandler = this.player.config.listeners[customHandlerKey]; - const hasCustomHandler = utils.is.function(customHandler); + const hasCustomHandler = is.function(customHandler); - utils.on(element, type, event => proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler); + on(element, type, event => proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler); }; // Play/pause toggle - on(this.player.elements.buttons.play, 'click', this.player.togglePlay, 'play'); + bind(this.player.elements.buttons.play, 'click', this.player.togglePlay, 'play'); // Pause - on(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart'); + bind(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart'); // Rewind - on(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind'); + bind(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind'); // Rewind - on(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward'); + bind(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward'); // Mute toggle - on( + bind( this.player.elements.buttons.mute, 'click', () => { @@ -478,10 +478,10 @@ class Listeners { ); // Captions toggle - on(this.player.elements.buttons.captions, 'click', this.player.toggleCaptions); + bind(this.player.elements.buttons.captions, 'click', this.player.toggleCaptions); // Fullscreen toggle - on( + bind( this.player.elements.buttons.fullscreen, 'click', () => { @@ -491,7 +491,7 @@ class Listeners { ); // Picture-in-Picture - on( + bind( this.player.elements.buttons.pip, 'click', () => { @@ -501,15 +501,15 @@ class Listeners { ); // Airplay - on(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay'); + bind(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay'); // Settings menu - on(this.player.elements.buttons.settings, 'click', event => { + bind(this.player.elements.buttons.settings, 'click', event => { controls.toggleMenu.call(this.player, event); }); // Settings menu - on(this.player.elements.settings.form, 'click', event => { + bind(this.player.elements.settings.form, 'click', event => { event.stopPropagation(); // Go back to home tab on click @@ -519,7 +519,7 @@ class Listeners { }; // Settings menu items - use event delegation as items are added/removed - if (utils.matches(event.target, this.player.config.selectors.inputs.language)) { + if (matches(event.target, this.player.config.selectors.inputs.language)) { proxy( event, () => { @@ -528,7 +528,7 @@ class Listeners { }, 'language', ); - } else if (utils.matches(event.target, this.player.config.selectors.inputs.quality)) { + } else if (matches(event.target, this.player.config.selectors.inputs.quality)) { proxy( event, () => { @@ -537,7 +537,7 @@ class Listeners { }, 'quality', ); - } else if (utils.matches(event.target, this.player.config.selectors.inputs.speed)) { + } else if (matches(event.target, this.player.config.selectors.inputs.speed)) { proxy( event, () => { @@ -553,14 +553,14 @@ class Listeners { }); // Set range input alternative "value", which matches the tooltip time (#954) - on(this.player.elements.inputs.seek, 'mousedown mousemove', event => { + bind(this.player.elements.inputs.seek, 'mousedown mousemove', event => { const clientRect = this.player.elements.progress.getBoundingClientRect(); const percent = 100 / clientRect.width * (event.pageX - clientRect.left); event.currentTarget.setAttribute('seek-value', percent); }); // Pause while seeking - on(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => { + bind(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => { const seek = event.currentTarget; const code = event.keyCode ? event.keyCode : event.which; @@ -590,7 +590,7 @@ class Listeners { }); // Seek - on( + bind( this.player.elements.inputs.seek, inputEvent, event => { @@ -599,7 +599,7 @@ class Listeners { // If it exists, use seek-value instead of "value" for consistency with tooltip time (#954) let seekTo = seek.getAttribute('seek-value'); - if (utils.is.empty(seekTo)) { + if (is.empty(seekTo)) { seekTo = seek.value; } @@ -612,8 +612,8 @@ class Listeners { // Current time invert // Only if one time element is used for both currentTime and duration - if (this.player.config.toggleInvert && !utils.is.element(this.player.elements.display.duration)) { - on(this.player.elements.display.currentTime, 'click', () => { + if (this.player.config.toggleInvert && !is.element(this.player.elements.display.duration)) { + bind(this.player.elements.display.currentTime, 'click', () => { // Do nothing if we're at the start if (this.player.currentTime === 0) { return; @@ -626,7 +626,7 @@ class Listeners { } // Volume - on( + bind( this.player.elements.inputs.volume, inputEvent, event => { @@ -637,21 +637,21 @@ class Listeners { // Polyfill for lower fill in <input type="range"> for webkit if (browser.isWebkit) { - on(utils.getElements.call(this.player, 'input[type="range"]'), 'input', event => { + bind(getElements.call(this.player, 'input[type="range"]'), 'input', event => { controls.updateRangeFill.call(this.player, event.target); }); } // Seek tooltip - on(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(this.player, event)); + bind(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(this.player, event)); // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting) - on(this.player.elements.controls, 'mouseenter mouseleave', event => { + bind(this.player.elements.controls, 'mouseenter mouseleave', event => { this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter'; }); // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting) - on(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { + bind(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { this.player.elements.controls.pressed = [ 'mousedown', 'touchstart', @@ -659,11 +659,11 @@ class Listeners { }); // Focus in/out on controls - on(this.player.elements.controls, 'focusin focusout', event => { + bind(this.player.elements.controls, 'focusin focusout', event => { const { config, elements, timers } = this.player; // Skip transition to prevent focus from scrolling the parent element - utils.toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin'); + toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin'); // Toggle ui.toggleControls.call(this.player, event.type === 'focusin'); @@ -672,7 +672,7 @@ class Listeners { if (event.type === 'focusin') { // Restore transition setTimeout(() => { - utils.toggleClass(elements.controls, config.classNames.noTransition, false); + toggleClass(elements.controls, config.classNames.noTransition, false); }, 0); // Delay a little more for keyboard users @@ -686,7 +686,7 @@ class Listeners { }); // Mouse wheel for volume - on( + bind( this.player.elements.inputs.volume, 'wheel', event => { diff --git a/src/js/media.js b/src/js/media.js index f10bea1f..189112a1 100644 --- a/src/js/media.js +++ b/src/js/media.js @@ -5,7 +5,7 @@ import html5 from './html5'; import vimeo from './plugins/vimeo'; import youtube from './plugins/youtube'; -import utils from './utils'; +import { createElement, toggleClass, wrap } from './utils/elements'; const media = { // Setup media @@ -17,29 +17,29 @@ const media = { } // Add type class - utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true); + toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true); // Add provider class - utils.toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true); + toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true); // Add video class for embeds // This will require changes if audio embeds are added if (this.isEmbed) { - utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true); + toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true); } // Inject the player wrapper if (this.isVideo) { // Create the wrapper div - this.elements.wrapper = utils.createElement('div', { + this.elements.wrapper = createElement('div', { class: this.config.classNames.video, }); // Wrap the video in a container - utils.wrap(this.media, this.elements.wrapper); + wrap(this.media, this.elements.wrapper); // Faux poster container - this.elements.poster = utils.createElement('div', { + this.elements.poster = createElement('div', { class: this.config.classNames.poster, }); diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index 0246e221..07eee58f 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -7,7 +7,12 @@ /* global google */ import i18n from '../i18n'; -import utils from '../utils'; +import { createElement } from './../utils/elements'; +import { trigger } from './../utils/events'; +import is from './../utils/is'; +import loadScript from './../utils/loadScript'; +import { formatTime } from './../utils/time'; +import { buildUrlParams } from './../utils/urls'; class Ads { /** @@ -44,7 +49,7 @@ class Ads { } get enabled() { - return this.player.isVideo && this.player.config.ads.enabled && !utils.is.empty(this.publisherId); + return this.player.isVideo && this.player.config.ads.enabled && !is.empty(this.publisherId); } /** @@ -53,9 +58,8 @@ class Ads { load() { if (this.enabled) { // Check if the Google IMA3 SDK is loaded or load it ourselves - if (!utils.is.object(window.google) || !utils.is.object(window.google.ima)) { - utils - .loadScript(this.player.config.urls.googleIMA.sdk) + if (!is.object(window.google) || !is.object(window.google.ima)) { + loadScript(this.player.config.urls.googleIMA.sdk) .then(() => { this.ready(); }) @@ -103,7 +107,7 @@ class Ads { const base = 'https://go.aniview.com/api/adserver6/vast/'; - return `${base}?${utils.buildUrlParams(params)}`; + return `${base}?${buildUrlParams(params)}`; } /** @@ -116,7 +120,7 @@ class Ads { */ setupIMA() { // Create the container for our advertisements - this.elements.container = utils.createElement('div', { + this.elements.container = createElement('div', { class: this.player.config.classNames.ads, }); this.player.elements.container.appendChild(this.elements.container); @@ -184,7 +188,7 @@ class Ads { } const update = () => { - const time = utils.formatTime(Math.max(this.manager.getRemainingTime(), 0)); + const time = formatTime(Math.max(this.manager.getRemainingTime(), 0)); const label = `${i18n.get('advertisement', this.player.config)} - ${time}`; this.elements.container.setAttribute('data-badge-text', label); }; @@ -212,14 +216,14 @@ class Ads { this.cuePoints = this.manager.getCuePoints(); // Add advertisement cue's within the time line if available - if (!utils.is.empty(this.cuePoints)) { + if (!is.empty(this.cuePoints)) { this.cuePoints.forEach(cuePoint => { if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) { const seekElement = this.player.elements.progress; - if (utils.is.element(seekElement)) { + if (is.element(seekElement)) { const cuePercentage = 100 / this.player.duration * cuePoint; - const cue = utils.createElement('span', { + const cue = createElement('span', { class: this.player.config.classNames.cues, }); @@ -266,7 +270,7 @@ class Ads { // Proxy event const dispatchEvent = type => { const event = `ads${type.replace(/_/g, '').toLowerCase()}`; - utils.dispatchEvent.call(this.player, this.player.media, event); + trigger.call(this.player, this.player.media, event); }; switch (event.type) { @@ -393,7 +397,7 @@ class Ads { this.player.on('seeked', () => { const seekedTime = this.player.currentTime; - if (utils.is.empty(this.cuePoints)) { + if (is.empty(this.cuePoints)) { return; } @@ -530,9 +534,9 @@ class Ads { trigger(event, ...args) { const handlers = this.events[event]; - if (utils.is.array(handlers)) { + if (is.array(handlers)) { handlers.forEach(handler => { - if (utils.is.function(handler)) { + if (is.function(handler)) { handler.apply(this, args); } }); @@ -546,7 +550,7 @@ class Ads { * @return {Ads} */ on(event, callback) { - if (!utils.is.array(this.events[event])) { + if (!is.array(this.events[event])) { this.events[event] = []; } @@ -577,7 +581,7 @@ class Ads { * @param {string} from */ clearSafetyTimer(from) { - if (!utils.is.nullOrUndefined(this.safetyTimer)) { + if (!is.nullOrUndefined(this.safetyTimer)) { this.player.debug.log(`Safety timer cleared from: ${from}`); clearTimeout(this.safetyTimer); diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index 652c920c..76a85424 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -5,7 +5,34 @@ import captions from './../captions'; import controls from './../controls'; import ui from './../ui'; -import utils from './../utils'; +import { createElement, replaceElement, toggleClass } from './../utils/elements'; +import { trigger } from './../utils/events'; +import fetch from './../utils/fetch'; +import is from './../utils/is'; +import loadScript from './../utils/loadScript'; +import { format, stripHTML } from './../utils/strings'; +import { buildUrlParams } from './../utils/urls'; + +// Parse Vimeo ID from URL +function parseId(url) { + if (is.empty(url)) { + return null; + } + + if (is.number(Number(url))) { + return url; + } + + const regex = /^.*(vimeo.com\/|video\/)(\d+).*/; + return url.match(regex) ? RegExp.$2 : url; +} + +// Get aspect ratio for dimensions +function getAspectRatio(width, height) { + const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h)); + const ratio = getRatio(width, height); + return `${width / ratio}:${height / ratio}`; +} // Set playback state and trigger change (only on actual change) function assurePlaybackState(play) { @@ -14,22 +41,21 @@ function assurePlaybackState(play) { } if (this.media.paused === play) { this.media.paused = !play; - utils.dispatchEvent.call(this, this.media, play ? 'play' : 'pause'); + trigger.call(this, this.media, play ? 'play' : 'pause'); } } const vimeo = { setup() { // Add embed class for responsive - utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true); + toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Set intial ratio vimeo.setAspectRatio.call(this); // Load the API if not already - if (!utils.is.object(window.Vimeo)) { - utils - .loadScript(this.config.urls.vimeo.sdk) + if (!is.object(window.Vimeo)) { + loadScript(this.config.urls.vimeo.sdk) .then(() => { vimeo.ready.call(this); }) @@ -44,7 +70,7 @@ const vimeo = { // Set aspect ratio // For Vimeo we have an extra 300% height <div> to hide the standard controls and UI setAspectRatio(input) { - const ratio = utils.is.string(input) ? input.split(':') : this.config.ratio.split(':'); + const ratio = is.string(input) ? input.split(':') : this.config.ratio.split(':'); const padding = 100 / ratio[0] * ratio[1]; this.elements.wrapper.style.paddingBottom = `${padding}%`; @@ -73,34 +99,34 @@ const vimeo = { gesture: 'media', playsinline: !this.config.fullscreen.iosNative, }; - const params = utils.buildUrlParams(options); + const params = buildUrlParams(options); // Get the source URL or ID let source = player.media.getAttribute('src'); // Get from <div> if needed - if (utils.is.empty(source)) { + if (is.empty(source)) { source = player.media.getAttribute(player.config.attributes.embed.id); } - const id = utils.parseVimeoId(source); + const id = parseId(source); // Build an iframe - const iframe = utils.createElement('iframe'); - const src = utils.format(player.config.urls.vimeo.iframe, id, params); + const iframe = createElement('iframe'); + const src = format(player.config.urls.vimeo.iframe, id, params); iframe.setAttribute('src', src); iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('allowtransparency', ''); iframe.setAttribute('allow', 'autoplay'); // Inject the package - const wrapper = utils.createElement('div', { class: player.config.classNames.embedContainer }); + const wrapper = createElement('div', { class: player.config.classNames.embedContainer }); wrapper.appendChild(iframe); - player.media = utils.replaceElement(wrapper, player.media); + player.media = replaceElement(wrapper, player.media); // Get poster image - utils.fetch(utils.format(player.config.urls.vimeo.api, id), 'json').then(response => { - if (utils.is.empty(response)) { + fetch(format(player.config.urls.vimeo.api, id), 'json').then(response => { + if (is.empty(response)) { return; } @@ -160,7 +186,7 @@ const vimeo = { // Set seeking state and trigger event media.seeking = true; - utils.dispatchEvent.call(player, media, 'seeking'); + trigger.call(player, media, 'seeking'); // If paused, mute until seek is complete Promise.resolve(restorePause && embed.setVolume(0)) @@ -187,7 +213,7 @@ const vimeo = { .setPlaybackRate(input) .then(() => { speed = input; - utils.dispatchEvent.call(player, player.media, 'ratechange'); + trigger.call(player, player.media, 'ratechange'); }) .catch(error => { // Hide menu item (and menu if empty) @@ -207,7 +233,7 @@ const vimeo = { set(input) { player.embed.setVolume(input).then(() => { volume = input; - utils.dispatchEvent.call(player, player.media, 'volumechange'); + trigger.call(player, player.media, 'volumechange'); }); }, }); @@ -219,11 +245,11 @@ const vimeo = { return muted; }, set(input) { - const toggle = utils.is.boolean(input) ? input : false; + const toggle = is.boolean(input) ? input : false; player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => { muted = toggle; - utils.dispatchEvent.call(player, player.media, 'volumechange'); + trigger.call(player, player.media, 'volumechange'); }); }, }); @@ -235,7 +261,7 @@ const vimeo = { return loop; }, set(input) { - const toggle = utils.is.boolean(input) ? input : player.config.loop.active; + const toggle = is.boolean(input) ? input : player.config.loop.active; player.embed.setLoop(toggle).then(() => { loop = toggle; @@ -272,7 +298,7 @@ const vimeo = { player.embed.getVideoWidth(), player.embed.getVideoHeight(), ]).then(dimensions => { - const ratio = utils.getAspectRatio(dimensions[0], dimensions[1]); + const ratio = getAspectRatio(dimensions[0], dimensions[1]); vimeo.setAspectRatio.call(this, ratio); }); @@ -290,13 +316,13 @@ const vimeo = { // Get current time player.embed.getCurrentTime().then(value => { currentTime = value; - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + trigger.call(player, player.media, 'timeupdate'); }); // Get duration player.embed.getDuration().then(value => { player.media.duration = value; - utils.dispatchEvent.call(player, player.media, 'durationchange'); + trigger.call(player, player.media, 'durationchange'); }); // Get captions @@ -306,7 +332,7 @@ const vimeo = { }); player.embed.on('cuechange', ({ cues = [] }) => { - const strippedCues = cues.map(cue => utils.stripHTML(cue.text)); + const strippedCues = cues.map(cue => stripHTML(cue.text)); captions.updateCues.call(player, strippedCues); }); @@ -315,11 +341,11 @@ const vimeo = { player.embed.getPaused().then(paused => { assurePlaybackState.call(player, !paused); if (!paused) { - utils.dispatchEvent.call(player, player.media, 'playing'); + trigger.call(player, player.media, 'playing'); } }); - if (utils.is.element(player.embed.element) && player.supported.ui) { + if (is.element(player.embed.element) && player.supported.ui) { const frame = player.embed.element; // Fix keyboard focus issues @@ -330,7 +356,7 @@ const vimeo = { player.embed.on('play', () => { assurePlaybackState.call(player, true); - utils.dispatchEvent.call(player, player.media, 'playing'); + trigger.call(player, player.media, 'playing'); }); player.embed.on('pause', () => { @@ -340,16 +366,16 @@ const vimeo = { player.embed.on('timeupdate', data => { player.media.seeking = false; currentTime = data.seconds; - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + trigger.call(player, player.media, 'timeupdate'); }); player.embed.on('progress', data => { player.media.buffered = data.percent; - utils.dispatchEvent.call(player, player.media, 'progress'); + trigger.call(player, player.media, 'progress'); // Check all loaded if (parseInt(data.percent, 10) === 1) { - utils.dispatchEvent.call(player, player.media, 'canplaythrough'); + trigger.call(player, player.media, 'canplaythrough'); } // Get duration as if we do it before load, it gives an incorrect value @@ -357,24 +383,24 @@ const vimeo = { player.embed.getDuration().then(value => { if (value !== player.media.duration) { player.media.duration = value; - utils.dispatchEvent.call(player, player.media, 'durationchange'); + trigger.call(player, player.media, 'durationchange'); } }); }); player.embed.on('seeked', () => { player.media.seeking = false; - utils.dispatchEvent.call(player, player.media, 'seeked'); + trigger.call(player, player.media, 'seeked'); }); player.embed.on('ended', () => { player.media.paused = true; - utils.dispatchEvent.call(player, player.media, 'ended'); + trigger.call(player, player.media, 'ended'); }); player.embed.on('error', detail => { player.media.error = detail; - utils.dispatchEvent.call(player, player.media, 'error'); + trigger.call(player, player.media, 'error'); }); // Rebuild UI diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index 9b067c8a..e486aa43 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -4,7 +4,24 @@ import controls from './../controls'; import ui from './../ui'; -import utils from './../utils'; +import { dedupe } from './../utils/arrays'; +import { createElement, replaceElement, toggleClass } from './../utils/elements'; +import { trigger } from './../utils/events'; +import fetch from './../utils/fetch'; +import is from './../utils/is'; +import loadImage from './../utils/loadImage'; +import loadScript from './../utils/loadScript'; +import { format, generateId } from './../utils/strings'; + +// Parse YouTube ID from URL +function parseId(url) { + if (is.empty(url)) { + return null; + } + + const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + return url.match(regex) ? RegExp.$2 : url; +} // Standardise YouTube quality unit function mapQualityUnit(input) { @@ -57,11 +74,11 @@ function mapQualityUnit(input) { } function mapQualityUnits(levels) { - if (utils.is.empty(levels)) { + if (is.empty(levels)) { return levels; } - return utils.dedupe(levels.map(level => mapQualityUnit(level))); + return dedupe(levels.map(level => mapQualityUnit(level))); } // Set playback state and trigger change (only on actual change) @@ -71,24 +88,24 @@ function assurePlaybackState(play) { } if (this.media.paused === play) { this.media.paused = !play; - utils.dispatchEvent.call(this, this.media, play ? 'play' : 'pause'); + trigger.call(this, this.media, play ? 'play' : 'pause'); } } const youtube = { setup() { // Add embed class for responsive - utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true); + toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Set aspect ratio youtube.setAspectRatio.call(this); // Setup API - if (utils.is.object(window.YT) && utils.is.function(window.YT.Player)) { + if (is.object(window.YT) && is.function(window.YT.Player)) { youtube.ready.call(this); } else { // Load the API - utils.loadScript(this.config.urls.youtube.sdk).catch(error => { + loadScript(this.config.urls.youtube.sdk).catch(error => { this.debug.warn('YouTube API failed to load', error); }); @@ -115,10 +132,10 @@ const youtube = { // Try via undocumented API method first // This method disappears now and then though... // https://github.com/sampotts/plyr/issues/709 - if (utils.is.function(this.embed.getVideoData)) { + if (is.function(this.embed.getVideoData)) { const { title } = this.embed.getVideoData(); - if (utils.is.empty(title)) { + if (is.empty(title)) { this.config.title = title; ui.setTitle.call(this); return; @@ -127,13 +144,12 @@ const youtube = { // Or via Google API const key = this.config.keys.google; - if (utils.is.string(key) && !utils.is.empty(key)) { - const url = utils.format(this.config.urls.youtube.api, videoId, key); + if (is.string(key) && !is.empty(key)) { + const url = format(this.config.urls.youtube.api, videoId, key); - utils - .fetch(url) + fetch(url) .then(result => { - if (utils.is.object(result)) { + if (is.object(result)) { this.config.title = result.items[0].snippet.title; ui.setTitle.call(this); } @@ -154,7 +170,7 @@ const youtube = { // Ignore already setup (race condition) const currentId = player.media.getAttribute('id'); - if (!utils.is.empty(currentId) && currentId.startsWith('youtube-')) { + if (!is.empty(currentId) && currentId.startsWith('youtube-')) { return; } @@ -162,23 +178,23 @@ const youtube = { let source = player.media.getAttribute('src'); // Get from <div> if needed - if (utils.is.empty(source)) { + if (is.empty(source)) { source = player.media.getAttribute(this.config.attributes.embed.id); } // Replace the <iframe> with a <div> due to YouTube API issues - const videoId = utils.parseYouTubeId(source); - const id = utils.generateId(player.provider); - const container = utils.createElement('div', { id }); - player.media = utils.replaceElement(container, player.media); + const videoId = parseId(source); + const id = generateId(player.provider); + const container = createElement('div', { id }); + player.media = replaceElement(container, player.media); // Set poster image const posterSrc = format => `https://img.youtube.com/vi/${videoId}/${format}default.jpg`; // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide) - utils.loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded - .catch(() => utils.loadImage(posterSrc('sd'), 121)) // 480p padded 4:3 - .catch(() => utils.loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists + loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded + .catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3 + .catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists .then(image => ui.setPoster.call(player, image.src)) .then(posterSrc => { // If the image is padded, use background-size "cover" instead (like youtube does too with their posters) @@ -213,7 +229,7 @@ const youtube = { onError(event) { // If we've already fired an error, don't do it again // YouTube fires onError twice - if (utils.is.object(player.media.error)) { + if (is.object(player.media.error)) { return; } @@ -250,10 +266,10 @@ const youtube = { player.media.error = detail; - utils.dispatchEvent.call(player, player.media, 'error'); + trigger.call(player, player.media, 'error'); }, onPlaybackQualityChange() { - utils.dispatchEvent.call(player, player.media, 'qualitychange', false, { + trigger.call(player, player.media, 'qualitychange', false, { quality: player.media.quality, }); }, @@ -264,7 +280,7 @@ const youtube = { // Get current speed player.media.playbackRate = instance.getPlaybackRate(); - utils.dispatchEvent.call(player, player.media, 'ratechange'); + trigger.call(player, player.media, 'ratechange'); }, onReady(event) { // Get the instance @@ -305,7 +321,7 @@ const youtube = { // Set seeking state and trigger event player.media.seeking = true; - utils.dispatchEvent.call(player, player.media, 'seeking'); + trigger.call(player, player.media, 'seeking'); // Seek after events sent instance.seekTo(time); @@ -334,7 +350,7 @@ const youtube = { instance.setPlaybackQuality(mapQualityUnit(quality)); // Trigger request event - utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, { + trigger.call(player, player.media, 'qualityrequested', false, { quality, }); }, @@ -349,7 +365,7 @@ const youtube = { set(input) { volume = input; instance.setVolume(volume * 100); - utils.dispatchEvent.call(player, player.media, 'volumechange'); + trigger.call(player, player.media, 'volumechange'); }, }); @@ -360,10 +376,10 @@ const youtube = { return muted; }, set(input) { - const toggle = utils.is.boolean(input) ? input : muted; + const toggle = is.boolean(input) ? input : muted; muted = toggle; instance[toggle ? 'mute' : 'unMute'](); - utils.dispatchEvent.call(player, player.media, 'volumechange'); + trigger.call(player, player.media, 'volumechange'); }, }); @@ -389,8 +405,8 @@ const youtube = { player.media.setAttribute('tabindex', -1); } - utils.dispatchEvent.call(player, player.media, 'timeupdate'); - utils.dispatchEvent.call(player, player.media, 'durationchange'); + trigger.call(player, player.media, 'timeupdate'); + trigger.call(player, player.media, 'durationchange'); // Reset timer clearInterval(player.timers.buffering); @@ -402,7 +418,7 @@ const youtube = { // Trigger progress only when we actually buffer something if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) { - utils.dispatchEvent.call(player, player.media, 'progress'); + trigger.call(player, player.media, 'progress'); } // Set last buffer point @@ -413,7 +429,7 @@ const youtube = { clearInterval(player.timers.buffering); // Trigger event - utils.dispatchEvent.call(player, player.media, 'canplaythrough'); + trigger.call(player, player.media, 'canplaythrough'); } }, 200); @@ -435,7 +451,7 @@ const youtube = { if (seeked) { // Unset seeking and fire seeked event player.media.seeking = false; - utils.dispatchEvent.call(player, player.media, 'seeked'); + trigger.call(player, player.media, 'seeked'); } // Handle events @@ -448,11 +464,11 @@ const youtube = { switch (event.data) { case -1: // Update scrubber - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + trigger.call(player, player.media, 'timeupdate'); // Get loaded % from YouTube player.media.buffered = instance.getVideoLoadedFraction(); - utils.dispatchEvent.call(player, player.media, 'progress'); + trigger.call(player, player.media, 'progress'); break; @@ -465,7 +481,7 @@ const youtube = { instance.stopVideo(); instance.playVideo(); } else { - utils.dispatchEvent.call(player, player.media, 'ended'); + trigger.call(player, player.media, 'ended'); } break; @@ -477,11 +493,11 @@ const youtube = { } else { assurePlaybackState.call(player, true); - utils.dispatchEvent.call(player, player.media, 'playing'); + trigger.call(player, player.media, 'playing'); // Poll to get playback progress player.timers.playing = setInterval(() => { - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + trigger.call(player, player.media, 'timeupdate'); }, 50); // Check duration again due to YouTube bug @@ -489,7 +505,7 @@ const youtube = { // https://code.google.com/p/gdata-issues/issues/detail?id=8690 if (player.media.duration !== instance.getDuration()) { player.media.duration = instance.getDuration(); - utils.dispatchEvent.call(player, player.media, 'durationchange'); + trigger.call(player, player.media, 'durationchange'); } // Get quality @@ -511,7 +527,7 @@ const youtube = { break; } - utils.dispatchEvent.call(player, player.elements.container, 'statechange', false, { + trigger.call(player, player.elements.container, 'statechange', false, { code: event.data, }); }, diff --git a/src/js/plyr.js b/src/js/plyr.js index 0786334d..1031efb2 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -6,9 +6,10 @@ // ========================================================================== import captions from './captions'; +import defaults from './config/defaults'; +import { getProviderByUrl, providers, types } from './config/types'; import Console from './console'; import controls from './controls'; -import defaults from './defaults'; import Fullscreen from './fullscreen'; import Listeners from './listeners'; import media from './media'; @@ -16,9 +17,14 @@ import Ads from './plugins/ads'; import source from './source'; import Storage from './storage'; import support from './support'; -import { providers, types } from './types'; import ui from './ui'; -import utils from './utils'; +import { closest } from './utils/arrays'; +import { createElement, hasClass, removeElement, replaceElement, toggleClass, toggleState, wrap } from './utils/elements'; +import { off, on, trigger } from './utils/events'; +import is from './utils/is'; +import loadSprite from './utils/loadScript'; +import { cloneDeep, extend } from './utils/objects'; +import { parseUrl } from './utils/urls'; // Private properties // TODO: Use a WeakMap for private globals @@ -41,18 +47,18 @@ class Plyr { this.media = target; // String selector passed - if (utils.is.string(this.media)) { + if (is.string(this.media)) { this.media = document.querySelectorAll(this.media); } // jQuery, NodeList or Array passed, use first element - if ((window.jQuery && this.media instanceof jQuery) || utils.is.nodeList(this.media) || utils.is.array(this.media)) { + if ((window.jQuery && this.media instanceof jQuery) || is.nodeList(this.media) || is.array(this.media)) { // eslint-disable-next-line this.media = this.media[0]; } // Set config - this.config = utils.extend( + this.config = extend( {}, defaults, Plyr.defaults, @@ -108,7 +114,7 @@ class Plyr { this.debug.log('Support', support); // We need an element to setup - if (utils.is.nullOrUndefined(this.media) || !utils.is.element(this.media)) { + if (is.nullOrUndefined(this.media) || !is.element(this.media)) { this.debug.error('Setup failed: no suitable element passed'); return; } @@ -144,7 +150,6 @@ class Plyr { // Embed properties let iframe = null; let url = null; - let params = null; // Different setup based on type switch (type) { @@ -153,10 +158,10 @@ class Plyr { iframe = this.media.querySelector('iframe'); // <iframe> type - if (utils.is.element(iframe)) { + if (is.element(iframe)) { // Detect provider - url = iframe.getAttribute('src'); - this.provider = utils.getProviderByUrl(url); + url = parseUrl(iframe.getAttribute('src')); + this.provider = getProviderByUrl(url.toString()); // Rework elements this.elements.container = this.media; @@ -166,24 +171,23 @@ class Plyr { this.elements.container.className = ''; // Get attributes from URL and set config - params = utils.getUrlParams(url); - if (!utils.is.empty(params)) { + if (!url.searchParams) { const truthy = [ '1', 'true', ]; - if (truthy.includes(params.autoplay)) { + if (truthy.includes(url.searchParams.get('autoplay'))) { this.config.autoplay = true; } - if (truthy.includes(params.loop)) { + if (truthy.includes(url.searchParams.get('loop'))) { this.config.loop.active = true; } // TODO: replace fullscreen.iosNative with this playsinline config option // YouTube requires the playsinline in the URL if (this.isYouTube) { - this.config.playsinline = truthy.includes(params.playsinline); + this.config.playsinline = truthy.includes(url.searchParams.get('playsinline')); } else { this.config.playsinline = true; } @@ -197,7 +201,7 @@ class Plyr { } // Unsupported or missing provider - if (utils.is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) { + if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) { this.debug.error('Setup failed: Invalid provider'); return; } @@ -255,9 +259,9 @@ class Plyr { this.media.plyr = this; // Wrap media - if (!utils.is.element(this.elements.container)) { - this.elements.container = utils.createElement('div'); - utils.wrap(this.media, this.elements.container); + if (!is.element(this.elements.container)) { + this.elements.container = createElement('div'); + wrap(this.media, this.elements.container); } // Allow focus to be captured @@ -271,7 +275,7 @@ class Plyr { // Listen for events if debugging if (this.config.debug) { - utils.on(this.elements.container, this.config.events.join(' '), event => { + on(this.elements.container, this.config.events.join(' '), event => { this.debug.log(`event: ${event.type}`); }); } @@ -330,7 +334,7 @@ class Plyr { * Play the media, or play the advertisement (if they are not blocked) */ play() { - if (!utils.is.function(this.media.play)) { + if (!is.function(this.media.play)) { return null; } @@ -342,7 +346,7 @@ class Plyr { * Pause the media */ pause() { - if (!this.playing || !utils.is.function(this.media.pause)) { + if (!this.playing || !is.function(this.media.pause)) { return; } @@ -383,7 +387,7 @@ class Plyr { */ togglePlay(input) { // Toggle based on current state if nothing passed - const toggle = utils.is.boolean(input) ? input : !this.playing; + const toggle = is.boolean(input) ? input : !this.playing; if (toggle) { this.play(); @@ -399,7 +403,7 @@ class Plyr { if (this.isHTML5) { this.pause(); this.restart(); - } else if (utils.is.function(this.media.stop)) { + } else if (is.function(this.media.stop)) { this.media.stop(); } } @@ -416,7 +420,7 @@ class Plyr { * @param {number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime */ rewind(seekTime) { - this.currentTime = this.currentTime - (utils.is.number(seekTime) ? seekTime : this.config.seekTime); + this.currentTime = this.currentTime - (is.number(seekTime) ? seekTime : this.config.seekTime); } /** @@ -424,7 +428,7 @@ class Plyr { * @param {number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime */ forward(seekTime) { - this.currentTime = this.currentTime + (utils.is.number(seekTime) ? seekTime : this.config.seekTime); + this.currentTime = this.currentTime + (is.number(seekTime) ? seekTime : this.config.seekTime); } /** @@ -438,7 +442,7 @@ class Plyr { } // Validate input - const inputIsValid = utils.is.number(input) && input > 0; + const inputIsValid = is.number(input) && input > 0; // Set this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0; @@ -461,7 +465,7 @@ class Plyr { const { buffered } = this.media; // YouTube / Vimeo return a float between 0-1 - if (utils.is.number(buffered)) { + if (is.number(buffered)) { return buffered; } @@ -505,17 +509,17 @@ class Plyr { const max = 1; const min = 0; - if (utils.is.string(volume)) { + if (is.string(volume)) { volume = Number(volume); } // Load volume from storage if no value specified - if (!utils.is.number(volume)) { + if (!is.number(volume)) { volume = this.storage.get('volume'); } // Use config if all else fails - if (!utils.is.number(volume)) { + if (!is.number(volume)) { ({ volume } = this.config); } @@ -535,7 +539,7 @@ class Plyr { this.media.volume = volume; // If muted, and we're increasing volume manually, reset muted state - if (!utils.is.empty(value) && this.muted && volume > 0) { + if (!is.empty(value) && this.muted && volume > 0) { this.muted = false; } } @@ -553,7 +557,7 @@ class Plyr { */ increaseVolume(step) { const volume = this.media.muted ? 0 : this.volume; - this.volume = volume + (utils.is.number(step) ? step : 1); + this.volume = volume + (is.number(step) ? step : 1); } /** @@ -562,7 +566,7 @@ class Plyr { */ decreaseVolume(step) { const volume = this.media.muted ? 0 : this.volume; - this.volume = volume - (utils.is.number(step) ? step : 1); + this.volume = volume - (is.number(step) ? step : 1); } /** @@ -573,12 +577,12 @@ class Plyr { let toggle = mute; // Load muted state from storage - if (!utils.is.boolean(toggle)) { + if (!is.boolean(toggle)) { toggle = this.storage.get('muted'); } // Use config if all else fails - if (!utils.is.boolean(toggle)) { + if (!is.boolean(toggle)) { toggle = this.config.muted; } @@ -624,15 +628,15 @@ class Plyr { set speed(input) { let speed = null; - if (utils.is.number(input)) { + if (is.number(input)) { speed = input; } - if (!utils.is.number(speed)) { + if (!is.number(speed)) { speed = this.storage.get('speed'); } - if (!utils.is.number(speed)) { + if (!is.number(speed)) { speed = this.config.speed.selected; } @@ -671,19 +675,19 @@ class Plyr { set quality(input) { let quality = null; - if (!utils.is.empty(input)) { + if (!is.empty(input)) { quality = Number(input); } - if (!utils.is.number(quality)) { + if (!is.number(quality)) { quality = this.storage.get('quality'); } - if (!utils.is.number(quality)) { + if (!is.number(quality)) { quality = this.config.quality.selected; } - if (!utils.is.number(quality)) { + if (!is.number(quality)) { quality = this.config.quality.default; } @@ -692,9 +696,9 @@ class Plyr { } if (!this.options.quality.includes(quality)) { - const closest = utils.closest(this.options.quality, quality); - this.debug.warn(`Unsupported quality option: ${quality}, using ${closest} instead`); - quality = closest; + const value = closest(this.options.quality, quality); + this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`); + quality = value; } // Update config @@ -717,7 +721,7 @@ class Plyr { * @param {boolean} input - Whether to loop or not */ set loop(input) { - const toggle = utils.is.boolean(input) ? input : this.config.loop.active; + const toggle = is.boolean(input) ? input : this.config.loop.active; this.config.loop.active = toggle; this.media.loop = toggle; @@ -816,7 +820,7 @@ class Plyr { * @param {boolean} input - Whether to autoplay or not */ set autoplay(input) { - const toggle = utils.is.boolean(input) ? input : this.config.autoplay; + const toggle = is.boolean(input) ? input : this.config.autoplay; this.config.autoplay = toggle; } @@ -838,18 +842,18 @@ class Plyr { } // If the method is called without parameter, toggle based on current value - const active = utils.is.boolean(input) ? input : !this.elements.container.classList.contains(this.config.classNames.captions.active); + const active = is.boolean(input) ? input : !this.elements.container.classList.contains(this.config.classNames.captions.active); // Toggle state - utils.toggleState(this.elements.buttons.captions, active); + toggleState(this.elements.buttons.captions, active); // Add class hook - utils.toggleClass(this.elements.container, this.config.classNames.captions.active, active); + toggleClass(this.elements.container, this.config.classNames.captions.active, active); // Update state and trigger event if (active !== this.captions.active) { this.captions.active = active; - utils.dispatchEvent.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled'); + trigger.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled'); } } @@ -902,7 +906,7 @@ class Plyr { } // Toggle based on current state if not passed - const toggle = utils.is.boolean(input) ? input : this.pip === states.inline; + const toggle = is.boolean(input) ? input : this.pip === states.inline; // Toggle based on current state this.media.webkitSetPresentationMode(toggle ? states.pip : states.inline); @@ -938,22 +942,22 @@ class Plyr { // Don't toggle if missing UI support or if it's audio if (this.supported.ui && !this.isAudio) { // Get state before change - const isHidden = utils.hasClass(this.elements.container, this.config.classNames.hideControls); + const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls); // Negate the argument if not undefined since adding the class to hides the controls const force = typeof toggle === 'undefined' ? undefined : !toggle; // Apply and get updated state - const hiding = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, force); + const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force); // Close menu - if (hiding && this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) { + if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) { controls.toggleMenu.call(this, false); } // Trigger event on change if (hiding !== isHidden) { const eventName = hiding ? 'controlshidden' : 'controlsshown'; - utils.dispatchEvent.call(this, this.media, eventName); + trigger.call(this, this.media, eventName); } return !hiding; } @@ -966,7 +970,7 @@ class Plyr { * @param {function} callback - Callback for when event occurs */ on(event, callback) { - utils.on(this.elements.container, event, callback); + on(this.elements.container, event, callback); } /** @@ -975,7 +979,7 @@ class Plyr { * @param {function} callback - Callback for when event occurs */ off(event, callback) { - utils.off(this.elements.container, event, callback); + off(this.elements.container, event, callback); } /** @@ -1001,10 +1005,10 @@ class Plyr { if (soft) { if (Object.keys(this.elements).length) { // Remove elements - utils.removeElement(this.elements.buttons.play); - utils.removeElement(this.elements.captions); - utils.removeElement(this.elements.controls); - utils.removeElement(this.elements.wrapper); + removeElement(this.elements.buttons.play); + removeElement(this.elements.captions); + removeElement(this.elements.controls); + removeElement(this.elements.wrapper); // Clear for GC this.elements.buttons.play = null; @@ -1014,7 +1018,7 @@ class Plyr { } // Callback - if (utils.is.function(callback)) { + if (is.function(callback)) { callback(); } } else { @@ -1022,13 +1026,13 @@ class Plyr { this.listeners.clear(); // Replace the container with the original element provided - utils.replaceElement(this.elements.original, this.elements.container); + replaceElement(this.elements.original, this.elements.container); // Event - utils.dispatchEvent.call(this, this.elements.original, 'destroyed', true); + trigger.call(this, this.elements.original, 'destroyed', true); // Callback - if (utils.is.function(callback)) { + if (is.function(callback)) { callback.call(this.elements.original); } @@ -1067,7 +1071,7 @@ class Plyr { clearInterval(this.timers.playing); // Destroy YouTube API - if (this.embed !== null && utils.is.function(this.embed.destroy)) { + if (this.embed !== null && is.function(this.embed.destroy)) { this.embed.destroy(); } @@ -1117,7 +1121,7 @@ class Plyr { * @param {string} [id] - Unique ID */ static loadSprite(url, id) { - return utils.loadSprite(url, id); + return loadSprite(url, id); } /** @@ -1128,15 +1132,15 @@ class Plyr { static setup(selector, options = {}) { let targets = null; - if (utils.is.string(selector)) { + if (is.string(selector)) { targets = Array.from(document.querySelectorAll(selector)); - } else if (utils.is.nodeList(selector)) { + } else if (is.nodeList(selector)) { targets = Array.from(selector); - } else if (utils.is.array(selector)) { - targets = selector.filter(utils.is.element); + } else if (is.array(selector)) { + targets = selector.filter(is.element); } - if (utils.is.empty(targets)) { + if (is.empty(targets)) { return null; } @@ -1144,6 +1148,6 @@ class Plyr { } } -Plyr.defaults = utils.cloneDeep(defaults); +Plyr.defaults = cloneDeep(defaults); export default Plyr; diff --git a/src/js/source.js b/src/js/source.js index e9a2938e..d4a66963 100644 --- a/src/js/source.js +++ b/src/js/source.js @@ -2,23 +2,24 @@ // Plyr source update // ========================================================================== +import { providers } from './config/types'; import html5 from './html5'; import media from './media'; import support from './support'; -import { providers } from './types'; import ui from './ui'; -import utils from './utils'; +import { createElement, insertElement, removeElement } from './utils/elements'; +import is from './utils/is'; const source = { // Add elements to HTML5 media (source, tracks, etc) insertElements(type, attributes) { - if (utils.is.string(attributes)) { - utils.insertElement(type, this.media, { + if (is.string(attributes)) { + insertElement(type, this.media, { src: attributes, }); - } else if (utils.is.array(attributes)) { + } else if (is.array(attributes)) { attributes.forEach(attribute => { - utils.insertElement(type, this.media, attribute); + insertElement(type, this.media, attribute); }); } }, @@ -26,7 +27,7 @@ const source = { // Update source // Sources are not checked for support so be careful change(input) { - if (!utils.is.object(input) || !('sources' in input) || !input.sources.length) { + if (!is.object(input) || !('sources' in input) || !input.sources.length) { this.debug.warn('Invalid source format'); return; } @@ -42,17 +43,17 @@ const source = { this.options.quality = []; // Remove elements - utils.removeElement(this.media); + removeElement(this.media); this.media = null; // Reset class name - if (utils.is.element(this.elements.container)) { + if (is.element(this.elements.container)) { this.elements.container.removeAttribute('class'); } // Set the type and provider this.type = input.type; - this.provider = !utils.is.empty(input.sources[0].provider) ? input.sources[0].provider : providers.html5; + this.provider = !is.empty(input.sources[0].provider) ? input.sources[0].provider : providers.html5; // Check for support this.supported = support.check(this.type, this.provider, this.config.playsinline); @@ -60,16 +61,16 @@ const source = { // Create new markup switch (`${this.provider}:${this.type}`) { case 'html5:video': - this.media = utils.createElement('video'); + this.media = createElement('video'); break; case 'html5:audio': - this.media = utils.createElement('audio'); + this.media = createElement('audio'); break; case 'youtube:video': case 'vimeo:video': - this.media = utils.createElement('div', { + this.media = createElement('div', { src: input.sources[0].src, }); break; @@ -82,7 +83,7 @@ const source = { this.elements.container.appendChild(this.media); // Autoplay the new source? - if (utils.is.boolean(input.autoplay)) { + if (is.boolean(input.autoplay)) { this.config.autoplay = input.autoplay; } @@ -94,7 +95,7 @@ const source = { if (this.config.autoplay) { this.media.setAttribute('autoplay', ''); } - if (!utils.is.empty(input.poster)) { + if (!is.empty(input.poster)) { this.poster = input.poster; } if (this.config.loop.active) { diff --git a/src/js/storage.js b/src/js/storage.js index e4dc9e1b..27fdad9f 100644 --- a/src/js/storage.js +++ b/src/js/storage.js @@ -2,7 +2,8 @@ // Plyr storage // ========================================================================== -import utils from './utils'; +import is from './utils/is'; +import { extend } from './utils/objects'; class Storage { constructor(player) { @@ -37,13 +38,13 @@ class Storage { const store = window.localStorage.getItem(this.key); - if (utils.is.empty(store)) { + if (is.empty(store)) { return null; } const json = JSON.parse(store); - return utils.is.string(key) && key.length ? json[key] : json; + return is.string(key) && key.length ? json[key] : json; } set(object) { @@ -53,7 +54,7 @@ class Storage { } // Can only store objectst - if (!utils.is.object(object)) { + if (!is.object(object)) { return; } @@ -61,12 +62,12 @@ class Storage { let storage = this.get(); // Default to empty object - if (utils.is.empty(storage)) { + if (is.empty(storage)) { storage = {}; } // Update the working copy of the values - utils.extend(storage, object); + extend(storage, object); // Update storage window.localStorage.setItem(this.key, JSON.stringify(storage)); diff --git a/src/js/support.js b/src/js/support.js index 38212d9f..7eabae3c 100644 --- a/src/js/support.js +++ b/src/js/support.js @@ -2,7 +2,10 @@ // Plyr support checks // ========================================================================== -import utils from './utils'; +import { transitionEndEvent } from './utils/animation'; +import browser from './utils/browser'; +import { createElement } from './utils/elements'; +import is from './utils/is'; // Check for feature support const support = { @@ -15,7 +18,6 @@ const support = { check(type, provider, playsinline) { let api = false; let ui = false; - const browser = utils.getBrowser(); const canPlayInline = browser.isIPhone && playsinline && support.playsinline; switch (`${provider}:${type}`) { @@ -48,14 +50,11 @@ const support = { // Picture-in-picture support // Safari only currently - pip: (() => { - const browser = utils.getBrowser(); - return !browser.isIPhone && utils.is.function(utils.createElement('video').webkitSetPresentationMode); - })(), + pip: (() => !browser.isIPhone && is.function(createElement('video').webkitSetPresentationMode))(), // Airplay support // Safari only currently - airplay: utils.is.function(window.WebKitPlaybackTargetAvailabilityEvent), + airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent), // Inline playback support // https://webkit.org/blog/6784/new-video-policies-for-ios/ @@ -69,7 +68,7 @@ const support = { try { // Bail if no checking function - if (!this.isHTML5 || !utils.is.function(media.canPlayType)) { + if (!this.isHTML5 || !is.function(media.canPlayType)) { return false; } @@ -119,28 +118,6 @@ const support = { // Check for textTracks support textTracks: 'textTracks' in document.createElement('video'), - // Check for passive event listener support - // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md - // https://www.youtube.com/watch?v=NPM6172J22g - passiveListeners: (() => { - // 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; - })(), - // <input type="range"> Sliders rangeInput: (() => { const range = document.createElement('input'); @@ -153,7 +130,7 @@ const support = { touch: 'ontouchstart' in document.documentElement, // Detect transitions support - transitions: utils.transitionEndEvent !== false, + transitions: transitionEndEvent !== false, // Reduced motion iOS & MacOS setting // https://webkit.org/blog/7551/responsive-design-for-motion/ diff --git a/src/js/ui.js b/src/js/ui.js index d6ab0e59..e3faf42f 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -6,15 +6,16 @@ import captions from './captions'; import controls from './controls'; import i18n from './i18n'; import support from './support'; -import utils from './utils'; - -// Sniff out the browser -const browser = utils.getBrowser(); +import browser from './utils/browser'; +import { getElement, toggleClass, toggleState } from './utils/elements'; +import { trigger } from './utils/events'; +import is from './utils/is'; +import loadImage from './utils/loadImage'; const ui = { addStyleHook() { - utils.toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true); - utils.toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui); + toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true); + toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui); }, // Toggle native HTML5 media controls @@ -44,7 +45,7 @@ const ui = { } // Inject custom controls if not present - if (!utils.is.element(this.elements.controls)) { + if (!is.element(this.elements.controls)) { // Inject custom controls controls.inject.call(this); @@ -85,23 +86,23 @@ const ui = { ui.checkPlaying.call(this); // Check for picture-in-picture support - utils.toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo); + toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo); // Check for airplay support - utils.toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5); + toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5); // Add iOS class - utils.toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos); + toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos); // Add touch class - utils.toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch); + toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch); // Ready for API calls this.ready = true; // Ready event at end of execution stack setTimeout(() => { - utils.dispatchEvent.call(this, this.media, 'ready'); + trigger.call(this, this.media, 'ready'); }, 0); // Set the title @@ -125,7 +126,7 @@ const ui = { let label = i18n.get('play', this.config); // If there's a media title set, use that for the label - if (utils.is.string(this.config.title) && !utils.is.empty(this.config.title)) { + if (is.string(this.config.title) && !is.empty(this.config.title)) { label += `, ${this.config.title}`; // Set container label @@ -133,7 +134,7 @@ const ui = { } // If there's a play button, set label - if (utils.is.nodeList(this.elements.buttons.play)) { + if (is.nodeList(this.elements.buttons.play)) { Array.from(this.elements.buttons.play).forEach(button => { button.setAttribute('aria-label', label); }); @@ -142,14 +143,14 @@ const ui = { // Set iframe title // https://github.com/sampotts/plyr/issues/124 if (this.isEmbed) { - const iframe = utils.getElement.call(this, 'iframe'); + const iframe = getElement.call(this, 'iframe'); - if (!utils.is.element(iframe)) { + if (!is.element(iframe)) { return; } // Default to media type - const title = !utils.is.empty(this.config.title) ? this.config.title : 'video'; + const title = !is.empty(this.config.title) ? this.config.title : 'video'; const format = i18n.get('frameTitle', this.config); iframe.setAttribute('title', format.replace('{title}', title)); @@ -158,7 +159,7 @@ const ui = { // Toggle poster togglePoster(enable) { - utils.toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable); + toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable); }, // Set the poster image (async) @@ -167,22 +168,21 @@ const ui = { this.media.setAttribute('poster', poster); // Bail if element is missing - if (!utils.is.element(this.elements.poster)) { + if (!is.element(this.elements.poster)) { return Promise.reject(); } // Load the image, and set poster if successful - const loadPromise = utils.loadImage(poster) - .then(() => { - this.elements.poster.style.backgroundImage = `url('${poster}')`; - Object.assign(this.elements.poster.style, { - backgroundImage: `url('${poster}')`, - // Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube) - backgroundSize: '', - }); - ui.togglePoster.call(this, true); - return poster; + const loadPromise = loadImage(poster).then(() => { + this.elements.poster.style.backgroundImage = `url('${poster}')`; + Object.assign(this.elements.poster.style, { + backgroundImage: `url('${poster}')`, + // Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube) + backgroundSize: '', }); + ui.togglePoster.call(this, true); + return poster; + }); // Hide the element if the poster can't be loaded (otherwise it will just be a black element covering the video) loadPromise.catch(() => ui.togglePoster.call(this, false)); @@ -194,15 +194,15 @@ const ui = { // Check playing state checkPlaying(event) { // Class hooks - utils.toggleClass(this.elements.container, this.config.classNames.playing, this.playing); - utils.toggleClass(this.elements.container, this.config.classNames.paused, this.paused); - utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped); + toggleClass(this.elements.container, this.config.classNames.playing, this.playing); + toggleClass(this.elements.container, this.config.classNames.paused, this.paused); + toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped); // Set ARIA state - utils.toggleState(this.elements.buttons.play, this.playing); + toggleState(this.elements.buttons.play, this.playing); // Only update controls on non timeupdate events - if (utils.is.event(event) && event.type === 'timeupdate') { + if (is.event(event) && event.type === 'timeupdate') { return; } @@ -223,7 +223,7 @@ const ui = { // Timer to prevent flicker when seeking this.timers.loading = setTimeout(() => { // Update progress bar loading class state - utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading); + toggleClass(this.elements.container, this.config.classNames.loading, this.loading); // Update controls visibility ui.toggleControls.call(this); diff --git a/src/js/utils.js b/src/js/utils.js deleted file mode 100644 index c36763dd..00000000 --- a/src/js/utils.js +++ /dev/null @@ -1,875 +0,0 @@ -// ========================================================================== -// Plyr utils -// ========================================================================== - -import loadjs from 'loadjs'; -import Storage from './storage'; -import support from './support'; -import { providers } from './types'; - -const utils = { - // Check variable types - is: { - object(input) { - return utils.getConstructor(input) === Object; - }, - number(input) { - return utils.getConstructor(input) === Number && !Number.isNaN(input); - }, - string(input) { - return utils.getConstructor(input) === String; - }, - boolean(input) { - return utils.getConstructor(input) === Boolean; - }, - function(input) { - return utils.getConstructor(input) === Function; - }, - array(input) { - return !utils.is.nullOrUndefined(input) && Array.isArray(input); - }, - weakMap(input) { - return utils.is.instanceof(input, WeakMap); - }, - nodeList(input) { - return utils.is.instanceof(input, NodeList); - }, - element(input) { - return utils.is.instanceof(input, Element); - }, - textNode(input) { - return utils.getConstructor(input) === Text; - }, - event(input) { - return utils.is.instanceof(input, Event); - }, - cue(input) { - return utils.is.instanceof(input, window.TextTrackCue) || utils.is.instanceof(input, window.VTTCue); - }, - track(input) { - return utils.is.instanceof(input, TextTrack) || (!utils.is.nullOrUndefined(input) && utils.is.string(input.kind)); - }, - url(input) { - return !utils.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 ( - utils.is.nullOrUndefined(input) || - ((utils.is.string(input) || utils.is.array(input) || utils.is.nodeList(input)) && !input.length) || - (utils.is.object(input) && !Object.keys(input).length) - ); - }, - instanceof(input, constructor) { - return Boolean(input && constructor && input instanceof constructor); - }, - }, - - getConstructor(input) { - return !utils.is.nullOrUndefined(input) ? input.constructor : null; - }, - - // 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), - }; - }, - - // Fetch wrapper - // Using XHR to avoid issues with older browsers - 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); - } - }); - }, - - // 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. - 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}); - }); - }, - - // Load an external script - loadScript(url) { - return new Promise((resolve, reject) => { - loadjs(url, { - success: resolve, - error: reject, - }); - }); - }, - - // Load an external SVG sprite - loadSprite(url, id) { - if (!utils.is.string(url)) { - return; - } - - const prefix = 'cache'; - const hasId = utils.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'); - utils.toggleHidden(container, true); - - 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 - utils - .fetch(url) - .then(result => { - if (utils.is.empty(result)) { - return; - } - - if (useStorage) { - window.localStorage.setItem( - `${prefix}-${id}`, - JSON.stringify({ - content: result, - }), - ); - } - - update(container, result); - }) - .catch(() => {}); - } - }, - - // Generate a random ID - generateId(prefix) { - return `${prefix}-${Math.floor(Math.random() * 10000)}`; - }, - - // 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); - } - }); - }, - - // 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.innerText = text; - } - - // Return built element - return element; - }, - - // Inaert an element after another - insertAfter(element, target) { - target.parentNode.insertBefore(element, target.nextSibling); - }, - - // Insert a DocumentFragment - insertElement(type, parent, attributes, text) { - // Inject the new <element> - parent.appendChild(utils.createElement(type, attributes, text)); - }, - - // Remove element(s) - removeElement(element) { - if (utils.is.nodeList(element) || utils.is.array(element)) { - Array.from(element).forEach(utils.removeElement); - return; - } - - if (!utils.is.element(element) || !utils.is.element(element.parentNode)) { - return; - } - - element.parentNode.removeChild(element); - }, - - // Remove all child elements - emptyElement(element) { - let { length } = element.childNodes; - - while (length > 0) { - element.removeChild(element.lastChild); - length -= 1; - } - }, - - // Replace element - replaceElement(newChild, oldChild) { - if (!utils.is.element(oldChild) || !utils.is.element(oldChild.parentNode) || !utils.is.element(newChild)) { - return null; - } - - oldChild.parentNode.replaceChild(newChild, oldChild); - - return newChild; - }, - - // Set attributes - setAttributes(element, attributes) { - if (!utils.is.element(element) || utils.is.empty(attributes)) { - return; - } - - Object.entries(attributes).forEach(([ - key, - value, - ]) => { - element.setAttribute(key, value); - }); - }, - - // 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 hidden - toggleHidden(element, hidden) { - if (!utils.is.element(element)) { - return; - } - - let hide = hidden; - - if (!utils.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 - toggleClass(element, className, force) { - if (utils.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 - hasClass(element, className) { - return utils.is.element(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); - }, - - // 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(element = null, toggle = false) { - if (!utils.is.element(element)) { - return; - } - - const focusable = utils.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 = utils.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(); - } - }; - - if (toggle) { - utils.on(this.elements.container, 'keydown', trap, false); - } else { - utils.off(this.elements.container, 'keydown', trap, false); - } - }, - - // Toggle event listener - toggleListener(elements, event, callback, toggle = false, passive = true, capture = false) { - // Bail if no elemetns, event, or callback - if (utils.is.empty(elements) || utils.is.empty(event) || !utils.is.function(callback)) { - return; - } - - // If a nodelist is passed, call itself on each node - if (utils.is.nodeList(elements) || utils.is.array(elements)) { - // 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 the capture boolean for browsers with no passive listener support - let options = capture; - - // If passive events listeners are supported - if (support.passiveListeners) { - 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 => { - elements[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); - }); - }, - - // Bind event handler - on(element, events = '', callback, passive = true, capture = false) { - utils.toggleListener(element, events, callback, true, passive, capture); - }, - - // Unbind event handler - off(element, events = '', callback, passive = true, capture = false) { - utils.toggleListener(element, events, callback, false, passive, capture); - }, - - // Trigger event - dispatchEvent(element, type = '', bubbles = false, detail = {}) { - // Bail if no element - if (!utils.is.element(element) || utils.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); - }, - - // Toggle aria-pressed state on a toggle button - // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles - toggleState(element, input) { - // If multiple elements passed - if (utils.is.array(element) || utils.is.nodeList(element)) { - Array.from(element).forEach(target => utils.toggleState(target, input)); - return; - } - - // Bail if no target - if (!utils.is.element(element)) { - return; - } - - // Get state - const pressed = element.getAttribute('aria-pressed') === 'true'; - const state = utils.is.boolean(input) ? input : !pressed; - - // Set the attribute on target - element.setAttribute('aria-pressed', state); - }, - - // Format string - format(input, ...args) { - if (utils.is.empty(input)) { - return input; - } - - return input.toString().replace(/{(\d+)}/g, (match, i) => (utils.is.string(args[i]) ? args[i] : '')); - }, - - // Get percentage - getPercentage(current, max) { - if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { - return 0; - } - - return (current / max * 100).toFixed(2); - }, - - // Time helpers - getHours(value) { - return parseInt((value / 60 / 60) % 60, 10); - }, - getMinutes(value) { - return parseInt((value / 60) % 60, 10); - }, - getSeconds(value) { - return parseInt(value % 60, 10); - }, - - // Format time to UI friendly string - formatTime(time = 0, displayHours = false, inverted = false) { - // Bail if the value isn't a number - if (!utils.is.number(time)) { - return utils.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 = utils.getHours(time); - const mins = utils.getMinutes(time); - const secs = utils.getSeconds(time); - - // Do we need to display hours? - if (displayHours || hours > 0) { - hours = `${hours}:`; - } else { - hours = ''; - } - - // Render - return `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; - }, - - // Replace all occurances of a string in a string - replaceAll(input = '', find = '', replace = '') { - return input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString()); - }, - - // Convert to title case - toTitleCase(input = '') { - return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase()); - }, - - // Convert string to pascalCase - toPascalCase(input = '') { - let string = input.toString(); - - // Convert kebab case - string = utils.replaceAll(string, '-', ' '); - - // Convert snake case - string = utils.replaceAll(string, '_', ' '); - - // Convert to title case - string = utils.toTitleCase(string); - - // Convert to pascal case - return utils.replaceAll(string, ' ', ''); - }, - - // Convert string to pascalCase - toCamelCase(input = '') { - let string = input.toString(); - - // Convert to pascal case - string = utils.toPascalCase(string); - - // Convert first character to lowercase - return string.charAt(0).toLowerCase() + string.slice(1); - }, - - // Deep extend destination object with N more objects - extend(target = {}, ...sources) { - if (!sources.length) { - return target; - } - - const source = sources.shift(); - - if (!utils.is.object(source)) { - return target; - } - - Object.keys(source).forEach(key => { - if (utils.is.object(source[key])) { - if (!Object.keys(target).includes(key)) { - Object.assign(target, { [key]: {} }); - } - - utils.extend(target[key], source[key]); - } else { - Object.assign(target, { [key]: source[key] }); - } - }); - - return utils.extend(target, ...sources); - }, - - // Remove duplicates in an array - dedupe(array) { - if (!utils.is.array(array)) { - return array; - } - - return array.filter((item, index) => array.indexOf(item) === index); - }, - - // Clone nested objects - cloneDeep(object) { - return JSON.parse(JSON.stringify(object)); - }, - - // Get a nested value in an object - getDeep(object, path) { - return path.split('.').reduce((obj, key) => obj && obj[key], object); - }, - - // Get the closest value in an array - closest(array, value) { - if (!utils.is.array(array) || !array.length) { - return null; - } - - return array.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev)); - }, - - // Get the provider for a given URL - getProviderByUrl(url) { - // YouTube - if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/.test(url)) { - return providers.youtube; - } - - // Vimeo - if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) { - return providers.vimeo; - } - - return null; - }, - - // Parse YouTube ID from URL - parseYouTubeId(url) { - if (utils.is.empty(url)) { - return null; - } - - 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.empty(url)) { - return null; - } - - if (utils.is.number(Number(url))) { - return url; - } - - const regex = /^.*(vimeo.com\/|video\/)(\d+).*/; - return url.match(regex) ? RegExp.$2 : url; - }, - - // Convert a URL to a location object - parseUrl(url) { - const parser = document.createElement('a'); - parser.href = url; - return parser; - }, - - // Get URL query parameters - getUrlParams(input) { - let search = input; - - // Parse URL if needed - if (input.startsWith('http://') || input.startsWith('https://')) { - ({ search } = utils.parseUrl(input)); - } - - if (utils.is.empty(search)) { - return null; - } - - const hashes = search.slice(search.indexOf('?') + 1).split('&'); - - return hashes.reduce((params, hash) => { - const [ - key, - val, - ] = hash.split('='); - - return Object.assign(params, { [key]: decodeURIComponent(val) }); - }, {}); - }, - - // Convert object to URL parameters - buildUrlParams(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; - }, - - // Like outerHTML, but also works for DocumentFragment - getHTML(element) { - const wrapper = document.createElement('div'); - wrapper.appendChild(element); - return wrapper.innerHTML; - }, - - // Get aspect ratio for dimensions - getAspectRatio(width, height) { - const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h)); - const ratio = getRatio(width, height); - return `${width / ratio}:${height / ratio}`; - }, - - // Get the transition end event - get 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 utils.is.string(type) ? events[type] : false; - }, - - // Force repaint of element - repaint(element) { - setTimeout(() => { - utils.toggleHidden(element, true); - element.offsetHeight; // eslint-disable-line - utils.toggleHidden(element, false); - }, 0); - }, -}; - -export default utils; 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..4d4f97cd --- /dev/null +++ b/src/js/utils/elements.js @@ -0,0 +1,307 @@ +// ========================================================================== +// Element utils +// ========================================================================== + +import { off, on } 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; + } + + Object.entries(attributes).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(); + } + }; + + if (toggle) { + on(this.elements.container, 'keydown', trap, false); + } else { + off(this.elements.container, 'keydown', trap, false); + } +} + +// Toggle aria-pressed state on a toggle button +// http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles +export function toggleState(element, input) { + // If multiple elements passed + if (is.array(element) || is.nodeList(element)) { + Array.from(element).forEach(target => toggleState(target, input)); + return; + } + + // Bail if no target + if (!is.element(element)) { + return; + } + + // Get state + const pressed = element.getAttribute('aria-pressed') === 'true'; + const state = is.boolean(input) ? input : !pressed; + + // Set the attribute on target + element.setAttribute('aria-pressed', state); +} diff --git a/src/js/utils/events.js b/src/js/utils/events.js new file mode 100644 index 00000000..cb92a93c --- /dev/null +++ b/src/js/utils/events.js @@ -0,0 +1,98 @@ +// ========================================================================== +// 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(elements, event, callback, toggle = false, passive = true, capture = false) { + // Bail if no elemetns, event, or callback + if (is.empty(elements) || is.empty(event) || !is.function(callback)) { + return; + } + + // If a nodelist is passed, call itself on each node + if (is.nodeList(elements) || is.array(elements)) { + // Create listener for each node + Array.from(elements).forEach(element => { + if (element instanceof Node) { + toggleListener.call(null, element, event, callback, toggle, passive, capture); + } + }); + + 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 => { + elements[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); +} + +// Bind event handler +export function on(element, events = '', callback, passive = true, capture = false) { + toggleListener(element, events, callback, true, passive, capture); +} + +// Unbind event handler +export function off(element, events = '', callback, passive = true, capture = false) { + toggleListener(element, events, callback, false, passive, capture); +} + +// Trigger event +export function trigger(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); +} 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..d34d3aed --- /dev/null +++ b/src/js/utils/is.js @@ -0,0 +1,64 @@ +// ========================================================================== +// 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..289aeee5 --- /dev/null +++ b/src/js/utils/strings.js @@ -0,0 +1,82 @@ +// ========================================================================== +// 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) => (is.string(args[i]) ? args[i] : '')); +} + +// 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..0c9fce64 --- /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 ? '-' : ''}${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..28323a1c --- /dev/null +++ b/src/js/utils/urls.js @@ -0,0 +1,44 @@ +// ========================================================================== +// 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) { + if (!is.object(input)) { + return ''; + } + + const params = new URLSearchParams(); + + Object.entries(input).forEach(([ + key, + value, + ]) => { + params.set(key, value); + }); + + return params; +} |