diff options
Diffstat (limited to 'src')
32 files changed, 1978 insertions, 2016 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index bafcf87e..732b2e38 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -6,7 +6,21 @@ import controls from './controls'; import i18n from './i18n'; import support from './support'; -import utils from './utils'; +import { dedupe } from './utils/arrays'; +import browser from './utils/browser'; +import { + createElement, + emptyElement, + getAttributesFromSelector, + insertAfter, + removeElement, + toggleClass, +} from './utils/elements'; +import { on, triggerEvent } 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 +33,11 @@ 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 +45,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,84 +58,96 @@ const captions = { Array.from(elements).forEach(track => { const src = track.getAttribute('src'); - const href = utils.parseUrl(src); - - if (href.hostname !== window.location.href.hostname && [ - 'http:', - 'https:', - ].includes(href.protocol)) { - utils - .fetch(src, 'blob') + const url = parseUrl(src); + + if ( + url !== null && + url.hostname !== window.location.href.hostname && + ['http:', 'https:'].includes(url.protocol) + ) { + fetch(src, 'blob') .then(blob => { track.setAttribute('src', window.URL.createObjectURL(blob)); }) .catch(() => { - utils.removeElement(track); + removeElement(track); }); } }); } - // Try to load the value from storage - let active = this.storage.get('captions'); + // Get and set initial data + // The "preferred" options are not realized unless / until the wanted language has a match + // * languages: Array of user's browser languages. + // * language: The language preferred by user settings or config + // * active: The state preferred by user settings or config + // * toggled: The real captions state - // Otherwise fall back to the default config - if (!utils.is.boolean(active)) { - ({ active } = this.config.captions); - } + const languages = dedupe( + Array.from(navigator.languages || navigator.userLanguage).map(language => language.split('-')[0]), + ); + + let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase(); - // Get language from storage, fallback to config - let language = this.storage.get('language') || this.config.captions.language; + // Use first browser language when language is 'auto' if (language === 'auto') { - [ language ] = (navigator.language || navigator.userLanguage).split('-'); + [language] = languages; } - // Set language and show if active - captions.setLanguage.call(this, language, active); + + let active = this.storage.get('captions'); + if (!is.boolean(active)) { + ({ active } = this.config.captions); + } + + Object.assign(this.captions, { + toggled: false, + active, + language, + languages, + }); // 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.call(this, this.media.textTracks, trackEvents, captions.update.bind(this)); } // Update available languages in list next tick (the event must not be triggered before the listeners) setTimeout(captions.update.bind(this), 0); }, + // Update available language options in settings based on tracks update() { const tracks = captions.getTracks.call(this, true); // Get the wanted language - const { language, meta } = this.captions; + const { active, language, meta, currentTrackNode } = this.captions; + const languageExists = Boolean(tracks.find(track => track.language === language)); // 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', }); - } - const trackRemoved = !tracks.find(track => track === this.captions.currentTrackNode); - const firstMatch = this.language !== language && tracks.find(track => track.language === language); + // Turn off native caption rendering to avoid double captions + track.mode = 'hidden'; - // Update language if removed or first matching track added - if (trackRemoved || firstMatch) { - captions.setLanguage.call(this, language, this.config.captions.active); + // Add event listener for cue changes + on.call(this, track, 'cuechange', () => captions.updateCues.call(this)); + }); + } + + // Update language first time it matches, or if the previous matching track was removed + if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) { + captions.setLanguage.call(this, language); + captions.toggle.call(this, active && languageExists); } // 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')) { @@ -128,16 +155,70 @@ const captions = { } }, - set(index, setLanguage = true, show = true) { + // Toggle captions display + // Used internally for the toggleCaptions method, with the passive option forced to false + toggle(input, passive = true) { + // If there's no full support + if (!this.supported.ui) { + return; + } + + const { toggled } = this.captions; // Current state + const activeClass = this.config.classNames.captions.active; + + // Get the next state + // If the method is called without parameter, toggle based on current value + const active = is.nullOrUndefined(input) ? !toggled : input; + + // Update state and trigger event + if (active !== toggled) { + // When passive, don't override user preferences + if (!passive) { + this.captions.active = active; + this.storage.set({ captions: active }); + } + + // Force language if the call isn't passive and there is no matching language to toggle to + if (!this.language && active && !passive) { + const tracks = captions.getTracks.call(this); + const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true); + + // Override user preferences to avoid switching languages if a matching track is added + this.captions.language = track.language; + + // Set caption, but don't store in localStorage as user preference + captions.set.call(this, tracks.indexOf(track)); + return; + } + + // Toggle state + this.elements.buttons.captions.pressed = active; + + // Add class hook + toggleClass(this.elements.container, activeClass, active); + + this.captions.toggled = active; + + // Update settings menu + controls.updateSetting.call(this, 'captions'); + + // Trigger event (not used internally) + triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled'); + } + }, + + // Set captions by track index + // Used internally for the currentTrack setter with the passive option forced to false + set(index, passive = true) { const tracks = captions.getTracks.call(this); // Disable captions if setting to -1 if (index === -1) { - this.toggleCaptions(false); + captions.toggle.call(this, false, passive); return; } - if (!utils.is.number(index)) { + if (!is.number(index)) { this.debug.warn('Invalid caption argument', index); return; } @@ -149,15 +230,19 @@ const captions = { if (this.captions.currentTrack !== index) { this.captions.currentTrack = index; - const track = captions.getCurrentTrack.call(this); + const track = tracks[index]; const { language } = track || {}; // Store reference to node for invalidation on remove this.captions.currentTrackNode = track; - // Prevent setting language in some cases, since it can violate user's intentions - if (setLanguage) { + // Update settings menu + controls.updateSetting.call(this, 'captions'); + + // When passive, don't override user preferences + if (!passive) { this.captions.language = language; + this.storage.set({ language }); } // Handle Vimeo captions @@ -166,32 +251,33 @@ const captions = { } // Trigger event - utils.dispatchEvent.call(this, this.media, 'languagechange'); + triggerEvent.call(this, this.media, 'languagechange'); } + // Show captions + captions.toggle.call(this, true, passive); + if (this.isHTML5 && this.isVideo) { // If we change the active track while a cue is already displayed we need to update it captions.updateCues.call(this); } - - // Show captions - if (show) { - this.toggleCaptions(true); - } }, - setLanguage(language, show = true) { - if (!utils.is.string(language)) { - this.debug.warn('Invalid language argument', language); + // Set captions by language + // Used internally for the language setter with the passive option forced to false + setLanguage(input, passive = true) { + if (!is.string(input)) { + this.debug.warn('Invalid language argument', input); return; } // Normalize - this.captions.language = language.toLowerCase(); + const language = input.toLowerCase(); + this.captions.language = language; // Set currentTrack const tracks = captions.getTracks.call(this); - const track = captions.getCurrentTrack.call(this, true); - captions.set.call(this, tracks.indexOf(track), false, show); + const track = captions.findTrack.call(this, [language]); + captions.set.call(this, tracks.indexOf(track), passive); }, // Get current valid caption tracks @@ -204,34 +290,42 @@ const captions = { // 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)); + .filter(track => ['captions', 'subtitles'].includes(track.kind)); }, - // Get the current track for the current language - getCurrentTrack(fromLanguage = false) { + // Match tracks based on languages and get the first + findTrack(languages, force = false) { const tracks = captions.getTracks.call(this); const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default); const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a)); - return (!fromLanguage && tracks[this.currentTrack]) || sorted.find(track => track.language === this.captions.language) || sorted[0]; + let track; + languages.every(language => { + track = sorted.find(track => track.language === language); + return !track; // Break iteration if there is a match + }); + // If no match is found but is required, get first + return track || (force ? sorted[0] : undefined); + }, + + // Get the current track + getCurrentTrack() { + return captions.getTracks.call(this)[this.currentTrack]; }, // Get UI label for track getLabel(track) { let currentTrack = track; - if (!utils.is.track(currentTrack) && support.textTracks && this.captions.active) { + if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) { 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 +343,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 +361,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 +370,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'); + triggerEvent.call(this, this.media, 'cuechange'); } }, }; diff --git a/src/js/defaults.js b/src/js/config/defaults.js index a31d0002..1e90a4f0 100644 --- a/src/js/defaults.js +++ b/src/js/config/defaults.js @@ -93,15 +93,7 @@ const defaults = { // Speed default and options to display speed: { selected: 1, - options: [ - 0.5, - 0.75, - 1, - 1.25, - 1.5, - 1.75, - 2, - ], + options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], }, // Keyboard shortcut settings @@ -155,11 +147,7 @@ const defaults = { 'airplay', 'fullscreen', ], - settings: [ - 'captions', - 'quality', - 'speed', - ], + settings: ['captions', 'quality', 'speed'], // Localisation i18n: { @@ -215,7 +203,8 @@ const defaults = { }, youtube: { sdk: 'https://www.youtube.com/iframe_api', - api: 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet', + api: + 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet', }, googleIMA: { sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.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 d386bc76..d79aaee7 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -6,11 +6,30 @@ 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 { dedupe } from './utils/arrays'; +import browser from './utils/browser'; +import { + createElement, + emptyElement, + getAttributesFromSelector, + getElement, + getElements, + hasClass, + removeElement, + setAttributes, + toggleClass, + toggleHidden, + matches, +} 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'; + +// TODO: Don't export a massive object - break down and create class const controls = { // Get icon URL getIconUrl() { @@ -23,46 +42,47 @@ const controls = { }; }, - // Find the UI controls and store references in custom controls - // TODO: Allow settings menus with custom controls + // Find the UI 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)) { - this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`); + if (is.element(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector( + `.${this.config.classNames.tooltip}`, + ); } return true; @@ -85,9 +105,9 @@ const controls = { // Create <svg> const icon = document.createElementNS(namespace, 'svg'); - utils.setAttributes( + setAttributes( icon, - utils.extend(attributes, { + extend(attributes, { role: 'presentation', focusable: 'false', }), @@ -113,44 +133,32 @@ const controls = { }, // Create hidden text label - createLabel(type, attr) { - let text = i18n.get(type, this.config); - const attributes = Object.assign({}, attr); - - switch (type) { - case 'pip': - text = 'PIP'; - break; - - case 'airplay': - text = 'AirPlay'; - break; - - default: - break; - } - - if ('class' in attributes) { - attributes.class += ` ${this.config.classNames.hidden}`; - } else { - attributes.class = this.config.classNames.hidden; - } + createLabel(type, attr = {}) { + // Skip i18n for abbreviations and brand names + const universals = { + pip: 'PIP', + airplay: 'AirPlay', + }; + const text = universals[type] || i18n.get(type, this.config); - return utils.createElement('span', attributes, text); + const attributes = Object.assign({}, attr, { + class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '), + }); + 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, @@ -164,9 +172,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; @@ -247,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] = []; } @@ -267,10 +275,10 @@ const controls = { Object.defineProperty(button, 'pressed', { enumerable: true, get() { - return utils.hasClass(button, className); + return hasClass(button, className); }, set(pressed = false) { - utils.toggleClass(button, className, pressed); + toggleClass(button, className, pressed); }, }); @@ -280,10 +288,10 @@ const controls = { // Create an <input type='range'> createRange(type, attributes) { // 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, @@ -312,10 +320,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, @@ -329,21 +337,13 @@ 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) { - case 'played': - suffix = i18n.get('played', this.config); - break; - - case 'buffer': - suffix = i18n.get('buffered', this.config); - break; - - default: - break; - } + const suffixKey = { + played: 'played', + buffer: 'buffered', + }[type]; + const suffix = suffixKey ? i18n.get(suffixKey, this.config) : ''; progress.innerText = `% ${suffix.toLowerCase()}`; } @@ -355,11 +355,11 @@ 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( + const container = createElement( 'div', - utils.extend(attributes, { + extend(attributes, { class: `plyr__time ${attributes.class}`, 'aria-label': i18n.get(type, this.config), }), @@ -373,16 +373,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, @@ -391,13 +391,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); } @@ -408,20 +408,20 @@ const controls = { // Format a time for display formatTime(time = 0, inverted = false) { // Bail if the value isn't a number - if (!utils.is.number(time)) { + if (!is.number(time)) { return time; } // Always display hours if duration is over an hour - const forceHours = utils.getHours(this.duration) > 0; + const forceHours = getHours(this.duration) > 0; - return utils.formatTime(time, forceHours, inverted); + return formatTime(time, forceHours, inverted); }, // 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; } @@ -436,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)) { + if (is.element(this.elements.buttons.mute)) { this.elements.buttons.mute.pressed = 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; } @@ -461,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; } } @@ -489,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') { @@ -514,21 +514,24 @@ 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; } // Set aria values for https://github.com/sampotts/plyr/issues/905 - if (utils.matches(range, this.config.selectors.inputs.seek)) { + if (matches(range, this.config.selectors.inputs.seek)) { range.setAttribute('aria-valuenow', this.currentTime); const currentTime = controls.formatTime(this.currentTime); const duration = controls.formatTime(this.duration); const format = i18n.get('seekLabel', this.config); - range.setAttribute('aria-valuetext', format.replace('{currentTime}', currentTime).replace('{duration}', duration)); - } else if (utils.matches(range, this.config.selectors.inputs.volume)) { + range.setAttribute( + 'aria-valuetext', + format.replace('{currentTime}', currentTime).replace('{duration}', duration), + ); + } else if (matches(range, this.config.selectors.inputs.volume)) { const percent = range.value * 100; range.setAttribute('aria-valuenow', percent); range.setAttribute('aria-valuetext', `${percent}%`); @@ -550,8 +553,8 @@ const controls = { // Bail if setting not true if ( !this.config.tooltips.seek || - !utils.is.element(this.elements.inputs.seek) || - !utils.is.element(this.elements.display.seekTooltip) || + !is.element(this.elements.inputs.seek) || + !is.element(this.elements.display.seekTooltip) || this.duration === 0 ) { return; @@ -563,7 +566,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 @@ -573,9 +576,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; @@ -596,10 +599,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) && [ - 'mouseenter', - 'mouseleave', - ].includes(event.type)) { + if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { toggle(event.type === 'mouseenter'); } }, @@ -607,10 +607,15 @@ 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); + controls.updateTimeDisplay.call( + this, + this.elements.display.currentTime, + invert ? this.duration - this.currentTime : this.currentTime, + invert, + ); // Ignore updates while seeking if (event && event.type === 'timeupdate' && this.media.seeking) { @@ -629,12 +634,12 @@ const controls = { } // Update ARIA values - if (utils.is.element(this.elements.inputs.seek)) { + if (is.element(this.elements.inputs.seek)) { this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration); } // 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) { @@ -652,27 +657,26 @@ 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; } const type = 'quality'; const list = this.elements.settings.panes.quality.querySelector('ul'); - // Set options if passed and filter based on config - if (utils.is.array(options)) { - this.options.quality = options.filter(quality => this.config.quality.options.includes(quality)); + // Set options if passed and filter based on uniqueness and config + if (is.array(options)) { + this.options.quality = dedupe(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 @@ -684,7 +688,7 @@ const controls = { } // Empty the menu - utils.emptyElement(list); + emptyElement(list); // Get the badge HTML for HD, 4K etc const getBadge = quality => { @@ -723,7 +727,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) { @@ -733,7 +737,7 @@ const controls = { return label; } - return utils.toTitleCase(value); + return toTitleCase(value); case 'captions': return captions.getLabel.call(this); @@ -749,41 +753,36 @@ const controls = { let value = null; let list = container; - switch (setting) { - case 'captions': - value = this.currentTrack; - break; - - default: - value = !utils.is.empty(input) ? input : this[setting]; - - // Get default - if (utils.is.empty(value)) { - value = this.config[setting].default; - } + if (setting === 'captions') { + value = this.currentTrack; + } else { + value = !is.empty(input) ? input : this[setting]; - // Unsupported value - if (!utils.is.empty(this.options[setting]) && !this.options[setting].includes(value)) { - this.debug.warn(`Unsupported value of '${value}' for ${setting}`); - return; - } + // Get default + if (is.empty(value)) { + value = this.config[setting].default; + } - // Disabled value - if (!this.config[setting].options.includes(value)) { - this.debug.warn(`Disabled value of '${value}' for ${setting}`); - return; - } + // Unsupported value + if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) { + this.debug.warn(`Unsupported value of '${value}' for ${setting}`); + return; + } - break; + // Disabled value + if (!this.config[setting].options.includes(value)) { + this.debug.warn(`Disabled value of '${value}' for ${setting}`); + return; + } } // 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; } @@ -794,7 +793,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; } }, @@ -802,7 +801,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; } @@ -810,22 +809,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, @@ -857,7 +856,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); @@ -870,7 +869,7 @@ const controls = { // Generate options data const options = tracks.map((track, value) => ({ value, - checked: this.captions.active && this.currentTrack === value, + checked: this.captions.toggled && this.currentTrack === value, title: captions.getLabel.call(this, track), badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()), list, @@ -880,7 +879,7 @@ const controls = { // Add the "Disabled" option to turn off captions options.unshift({ value: -1, - checked: !this.captions.active, + checked: !this.captions.toggled, title: i18n.get('disabled', this.config), list, type: 'language', @@ -900,32 +899,24 @@ 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 = [ - 0.5, - 0.75, - 1, - 1.25, - 1.5, - 1.75, - 2, - ]; + this.options.speed = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; } // Set options if passed and filter based on config 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 @@ -940,7 +931,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 => { @@ -958,9 +949,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 @@ -969,14 +960,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 @@ -993,13 +984,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'); @@ -1030,7 +1021,7 @@ const controls = { const height = clone.scrollHeight; // Remove from the DOM - utils.removeElement(clone); + removeElement(clone); return { width, @@ -1044,11 +1035,11 @@ const controls = { const pane = document.getElementById(target); // Nothing to show, bail - if (!utils.is.element(pane)) { + if (!is.element(pane)) { return; } - // Are we targetting a tab? If not, bail + // Are we targeting a tab? If not, bail const isTab = pane.getAttribute('role') === 'tabpanel'; if (!isTab) { return; @@ -1076,10 +1067,7 @@ const controls = { // Restore auto height/width const restore = e => { // We're only bothered about height and width on the container - if (e.target !== container || ![ - 'width', - 'height', - ].includes(e.propertyName)) { + if (e.target !== container || !['width', 'height'].includes(e.propertyName)) { return; } @@ -1088,11 +1076,11 @@ const controls = { container.style.height = ''; // Only listen once - utils.off(container, utils.transitionEndEvent, restore); + off.call(this, container, transitionEndEvent, restore); }; // Listen for the transition finishing and restore auto height/width - utils.on(container, utils.transitionEndEvent, restore); + on.call(this, container, transitionEndEvent, restore); // Set dimensions to target container.style.width = `${size.width}px`; @@ -1100,13 +1088,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); }); @@ -1120,12 +1108,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')) { @@ -1149,7 +1137,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 progress.appendChild( @@ -1165,7 +1153,7 @@ const controls = { // Seek tooltip if (this.config.tooltips.seek) { - const tooltip = utils.createElement( + const tooltip = createElement( 'span', { class: this.config.classNames.tooltip, @@ -1198,7 +1186,7 @@ const controls = { // Volume range control if (this.config.controls.includes('volume')) { - const volume = utils.createElement('div', { + const volume = createElement('div', { class: 'plyr__volume', }); @@ -1214,7 +1202,7 @@ const controls = { controls.createRange.call( this, 'volume', - utils.extend(attributes, { + extend(attributes, { id: `plyr-volume-${data.id}`, }), ), @@ -1231,8 +1219,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: '', }); @@ -1246,7 +1234,7 @@ const controls = { }), ); - const form = utils.createElement('form', { + const form = createElement('form', { class: 'plyr__menu__container', id: `plyr-settings-${data.id}`, hidden: '', @@ -1255,29 +1243,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`, @@ -1288,7 +1276,7 @@ const controls = { i18n.get(type, this.config), ); - const value = utils.createElement('span', { + const value = createElement('span', { class: this.config.classNames.menu.value, }); @@ -1307,7 +1295,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`, @@ -1315,7 +1303,7 @@ const controls = { tabindex: -1, }); - const back = utils.createElement( + const back = createElement( 'button', { type: 'button', @@ -1329,7 +1317,7 @@ const controls = { pane.appendChild(back); - const options = utils.createElement('ul'); + const options = createElement('ul'); pane.appendChild(options); inner.appendChild(pane); @@ -1384,7 +1372,7 @@ const controls = { // Only load external sprite using AJAX if (icon.cors) { - utils.loadSprite(icon.url, 'sprite-plyr'); + loadSprite(icon.url, 'sprite-plyr'); } } @@ -1403,10 +1391,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); @@ -1428,11 +1416,8 @@ const controls = { const replace = input => { let result = input; - Object.entries(props).forEach(([ - key, - value, - ]) => { - result = utils.replaceAll(result, `{${key}}`, value); + Object.entries(props).forEach(([key, value]) => { + result = replaceAll(result, `{${key}}`, value); }); return result; @@ -1440,9 +1425,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); } } @@ -1451,48 +1436,41 @@ 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( - this, - [ - this.config.selectors.controls.wrapper, - ' ', - this.config.selectors.labels, - ' .', - this.config.classNames.hidden, - ].join(''), - ); + const { classNames, selectors } = this.config; + const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`; + const labels = getElements.call(this, selector); 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); }); } }, diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js index cc91d1a4..d9cba17f 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, trapFocus } from './utils/elements'; +import { on, triggerEvent } 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)) { + if (is.element(button)) { button.pressed = this.active; } // Trigger an event - utils.dispatchEvent.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true); + triggerEvent.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,20 @@ class Fullscreen { // Register event listeners // Handle event (incase user presses escape etc) - utils.on(document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => { + on.call( + this.player, + 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.call(this.player, 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; } @@ -83,26 +89,27 @@ class Fullscreen { // Determine if native supported static get native() { - return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled); + return !!( + document.fullscreenEnabled || + document.webkitFullscreenEnabled || + document.mozFullScreenEnabled || + document.msFullscreenEnabled + ); } // Get the prefix for handlers static get prefix() { // No prefix - if (utils.is.function(document.exitFullscreen)) { + if (is.function(document.exitFullscreen)) { return ''; } // Check for fullscreen support by vendor prefix let value = ''; - const prefixes = [ - 'webkit', - 'moz', - 'ms', - ]; + const prefixes = ['webkit', 'moz', 'ms']; 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 +142,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`]; @@ -145,7 +152,9 @@ class Fullscreen { // Get target element get target() { - return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.container; + return browser.isIos && this.player.config.fullscreen.iosNative + ? this.player.media + : this.player.elements.container; } // Update UI @@ -157,7 +166,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 +184,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 +203,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..6aa96f4c 100644 --- a/src/js/html5.js +++ b/src/js/html5.js @@ -3,40 +3,28 @@ // ========================================================================== import support from './support'; -import utils from './utils'; +import { removeElement } from './utils/elements'; +import { triggerEvent } from './utils/events'; const html5 = { getSources() { if (!this.isHTML5) { - return null; + return []; } - return this.media.querySelectorAll('source'); + const sources = Array.from(this.media.querySelectorAll('source')); + + // Filter out unsupported sources + return sources.filter(source => support.mime.call(this, source.getAttribute('type'))); }, // Get quality levels getQualityOptions() { - if (!this.isHTML5) { - return null; - } - - // Get sources - const sources = html5.getSources.call(this); - - if (utils.is.empty(sources)) { - return null; - } - - // Get <source> with size attribute - const sizes = Array.from(sources).filter(source => !utils.is.empty(source.getAttribute('size'))); - - // If none, bail - if (utils.is.empty(sizes)) { - return null; - } - - // Reduce to unique list - return utils.dedupe(sizes.map(source => Number(source.getAttribute('size')))); + // Get sizes from <source> elements + return html5.getSources + .call(this) + .map(source => Number(source.getAttribute('size'))) + .filter(Boolean); }, extend() { @@ -51,60 +39,34 @@ const html5 = { get() { // Get sources const sources = html5.getSources.call(player); + const [source] = sources.filter(source => source.getAttribute('src') === player.source); - if (utils.is.empty(sources)) { - return null; - } - - const matches = Array.from(sources).filter(source => source.getAttribute('src') === player.source); - - if (utils.is.empty(matches)) { - return null; - } - - return Number(matches[0].getAttribute('size')); + // Return size, if match is found + return source && Number(source.getAttribute('size')); }, set(input) { // Get sources const sources = html5.getSources.call(player); - if (utils.is.empty(sources)) { - return; - } + // Get first match for requested size + const source = sources.find(source => Number(source.getAttribute('size')) === input); - // Get matches for requested size - const matches = Array.from(sources).filter(source => Number(source.getAttribute('size')) === input); - - // No matches for requested size - if (utils.is.empty(matches)) { + // No matching source found + if (!source) { return; } - // Get supported sources - const supported = matches.filter(source => support.mime.call(player, source.getAttribute('type'))); - - // No supported sources - if (utils.is.empty(supported)) { - return; - } - - // Trigger change event - utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, { - quality: input, - }); - // Get current state const { currentTime, playing } = player; // Set new source - player.media.src = supported[0].getAttribute('src'); + player.media.src = source.getAttribute('src'); // Restore time const onLoadedMetaData = () => { player.currentTime = currentTime; - player.off('loadedmetadata', onLoadedMetaData); }; - player.on('loadedmetadata', onLoadedMetaData); + player.once('loadedmetadata', onLoadedMetaData); // Load new source player.media.load(); @@ -115,7 +77,7 @@ const html5 = { } // Trigger change event - utils.dispatchEvent.call(player, player.media, 'qualitychange', false, { + triggerEvent.call(player, player.media, 'qualitychange', false, { quality: input, }); }, @@ -130,7 +92,7 @@ const html5 = { } // Remove child sources - utils.removeElement(html5.getSources()); + removeElement(html5.getSources.call(this)); // 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..5b0ebbab 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 ''; } @@ -21,11 +23,8 @@ const i18n = { '{title}': config.title, }; - Object.entries(replace).forEach(([ - key, - value, - ]) => { - string = utils.replaceAll(string, key, value); + Object.entries(replace).forEach(([key, value]) => { + string = replaceAll(string, key, value); }); return string; diff --git a/src/js/listeners.js b/src/js/listeners.js index c391ea4c..9d987508 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 { on, once, toggleListener, triggerEvent } 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; } @@ -46,37 +46,16 @@ class Listeners { // Reset on keyup if (pressed) { // Which keycodes should we prevent default - const preventDefault = [ - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 56, - 57, - 32, - 75, - 38, - 40, - 77, - 39, - 37, - 70, - 67, - 73, - 76, - 79, - ]; + const preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79]; // 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) && ( - focused !== this.player.elements.inputs.seek && - utils.matches(focused, this.player.config.selectors.editable)) + const focused = getFocusElement(); + if ( + is.element(focused) && + (focused !== this.player.elements.inputs.seek && + matches(focused, this.player.config.selectors.editable)) ) { return; } @@ -195,41 +174,37 @@ class Listeners { this.player.touch = true; // Add touch class - utils.toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true); - - // Clean up - utils.off(document.body, 'touchstart', this.firstTouch); + toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true); } // 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.call(this.player, window, 'keydown keyup', this.handleKey, toggle, false); } // Click anywhere closes menu - utils.toggleListener(document.body, 'click', this.toggleMenu, toggle); + toggleListener.call(this.player, document.body, 'click', this.toggleMenu, toggle); // Detect touch by events - utils.on(document.body, 'touchstart', this.firstTouch); + once.call(this.player, 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.call(this.player, 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.call(this.player, 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.call(this.player, this.player.elements.container, 'keydown', event => { if (event.keyCode !== 9) { return; } @@ -237,59 +212,64 @@ 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 => { - const { controls } = this.player.elements; + on.call( + this.player, + this.player.elements.container, + 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', + event => { + const { controls } = this.player.elements; - // Remove button states for fullscreen - if (event.type === 'enterfullscreen') { - controls.pressed = false; - controls.hover = false; - } + // Remove button states for fullscreen + if (event.type === 'enterfullscreen') { + controls.pressed = false; + controls.hover = false; + } - // Show, then hide after a timeout unless another control event occurs - const show = [ - 'touchstart', - 'touchmove', - 'mousemove', - ].includes(event.type); + // Show, then hide after a timeout unless another control event occurs + const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type); - let delay = 0; + let delay = 0; - if (show) { - ui.toggleControls.call(this.player, true); - // Use longer timeout for touch devices - delay = this.player.touch ? 3000 : 2000; - } + if (show) { + ui.toggleControls.call(this.player, true); + // Use longer timeout for touch devices + delay = this.player.touch ? 3000 : 2000; + } - // Clear timer - clearTimeout(this.player.timers.controls); - // Timer to prevent flicker when seeking - this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay); - }); + // Clear timer + clearTimeout(this.player.timers.controls); + // Timer to prevent flicker when seeking + this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay); + }, + ); } // 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.call(this.player, 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.call(this.player, 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.call(this.player, this.player.media, '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.call(this.player, this.player.media, 'ended', () => { // Show poster on end if (this.player.isHTML5 && this.player.isVideo && this.player.config.resetOnEnd) { // Restart @@ -298,20 +278,28 @@ class Listeners { }); // Check for buffer progress - utils.on(this.player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(this.player, event)); + on.call(this.player, 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.call(this.player, 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.call(this.player, 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.call(this.player, 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.call(this.player, this.player.media, 'playing', () => { if (!this.player.ads) { return; } @@ -326,15 +314,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.call(this.player, 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 +341,8 @@ class Listeners { // Disable right click if (this.player.supported.ui && this.player.config.disableContextMenu) { - utils.on( + on.call( + this.player, this.player.elements.wrapper, 'contextmenu', event => { @@ -364,13 +353,13 @@ class Listeners { } // Volume change - utils.on(this.player.media, 'volumechange', () => { + on.call(this.player, 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.call(this.player, this.player.media, 'ratechange', () => { // Update UI controls.updateSetting.call(this.player, 'speed'); @@ -379,49 +368,29 @@ class Listeners { }); // Quality request - utils.on(this.player.media, 'qualityrequested', event => { + on.call(this.player, 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.call(this.player, 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', () => { - // Update UI - controls.updateSetting.call(this.player, 'captions'); - - // Save to storage - this.player.storage.set({ language: this.player.language }); - }); - - // Captions toggle - utils.on(this.player.media, 'captionsenabled captionsdisabled', () => { - // Update UI - controls.updateSetting.call(this.player, 'captions'); - - // Save to storage - this.player.storage.set({ captions: this.player.captions.active }); - }); - // Proxy events to container // Bubble up key events for Edge - utils.on(this.player.media, this.player.config.events.concat([ - 'keyup', - 'keydown', - ]).join(' '), event => { - let {detail = {}} = event; + const proxyEvents = this.player.config.events.concat(['keyup', 'keydown']).join(' '); + on.call(this.player, this.player.media, proxyEvents, event => { + let { detail = {} } = event; // Get error details from media if (event.type === 'error') { detail = this.player.media.error; } - utils.dispatchEvent.call(this.player, this.player.elements.container, event.type, true, detail); + triggerEvent.call(this.player, this.player.elements.container, event.type, true, detail); }); } @@ -433,7 +402,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 +411,41 @@ 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); - - utils.on(element, type, event => proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler); + const hasCustomHandler = is.function(customHandler); + + on.call( + this.player, + element, + type, + event => proxy(event, defaultHandler, customHandlerKey), + passive && !hasCustomHandler, + ); }; // Play/pause toggle - on(this.player.elements.buttons.play, 'click', this.player.togglePlay, 'play'); + Array.from(this.player.elements.buttons.play).forEach(button => { + bind(button, '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 +455,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 +468,7 @@ class Listeners { ); // Picture-in-Picture - on( + bind( this.player.elements.buttons.pip, 'click', () => { @@ -501,15 +478,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 +496,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 +505,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 +514,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 +530,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; @@ -573,11 +550,7 @@ class Listeners { const play = seek.hasAttribute('play-on-seeked'); // Done seeking - const done = [ - 'mouseup', - 'touchend', - 'keyup', - ].includes(event.type); + const done = ['mouseup', 'touchend', 'keyup'].includes(event.type); // If we're done seeking and it was playing, resume playback if (play && done) { @@ -590,7 +563,7 @@ class Listeners { }); // Seek - on( + bind( this.player.elements.inputs.seek, inputEvent, event => { @@ -599,7 +572,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 +585,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 +599,7 @@ class Listeners { } // Volume - on( + bind( this.player.elements.inputs.volume, inputEvent, event => { @@ -637,33 +610,32 @@ 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 => { - controls.updateRangeFill.call(this.player, event.target); + Array.from(getElements.call(this.player, 'input[type="range"]')).forEach(element => { + bind(element, '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 => { - this.player.elements.controls.pressed = [ - 'mousedown', - 'touchstart', - ].includes(event.type); + bind(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { + this.player.elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type); }); // 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 +644,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 +658,7 @@ class Listeners { }); // Mouse wheel for volume - on( + bind( this.player.elements.inputs.volume, 'wheel', event => { @@ -719,7 +691,10 @@ class Listeners { } // Don't break page scrolling at max and min - if ((direction === 1 && this.player.media.volume < 1) || (direction === -1 && this.player.media.volume > 0)) { + if ( + (direction === 1 && this.player.media.volume < 1) || + (direction === -1 && this.player.media.volume > 0) + ) { event.preventDefault(); } }, @@ -727,11 +702,6 @@ class Listeners { false, ); } - - // Reset on destroy - clear() { - this.global(false); - } } export default Listeners; diff --git a/src/js/media.js b/src/js/media.js index f10bea1f..eb37d441 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,50 +17,41 @@ 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, }); this.elements.wrapper.appendChild(this.elements.poster); } - if (this.isEmbed) { - switch (this.provider) { - case 'youtube': - youtube.setup.call(this); - break; - - case 'vimeo': - vimeo.setup.call(this); - break; - - default: - break; - } - } else if (this.isHTML5) { + if (this.isHTML5) { html5.extend.call(this); + } else if (this.isYouTube) { + youtube.setup.call(this); + } else if (this.isVimeo) { + vimeo.setup.call(this); } }, }; diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index 0246e221..e0d49265 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 { triggerEvent } 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); @@ -146,7 +150,11 @@ class Ads { this.loader = new google.ima.AdsLoader(this.elements.displayContainer); // Listen and respond to ads loaded and error events - this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false); + this.loader.addEventListener( + google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, + event => this.onAdsManagerLoaded(event), + false, + ); this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false); // Request video ads @@ -184,7 +192,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 +220,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 +274,7 @@ class Ads { // Proxy event const dispatchEvent = type => { const event = `ads${type.replace(/_/g, '').toLowerCase()}`; - utils.dispatchEvent.call(this.player, this.player.media, event); + triggerEvent.call(this.player, this.player.media, event); }; switch (event.type) { @@ -393,7 +401,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 +538,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 +554,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 +585,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..09339229 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 { triggerEvent } 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'); + triggerEvent.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,8 +70,8 @@ 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 padding = 100 / ratio[0] * ratio[1]; + const [x, y] = (is.string(input) ? input : this.config.ratio).split(':'); + const padding = 100 / x * y; this.elements.wrapper.style.paddingBottom = `${padding}%`; if (this.supported.ui) { @@ -73,34 +99,37 @@ 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'); + // Get poster, if already set + const { poster } = player; + // Inject the package - const wrapper = utils.createElement('div', { class: player.config.classNames.embedContainer }); + const wrapper = createElement('div', { poster, 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; } @@ -111,7 +140,7 @@ const vimeo = { url.pathname = `${url.pathname.split('_')[0]}.jpg`; // Set and show poster - ui.setPoster.call(player, url.href); + ui.setPoster.call(player, url.href).catch(() => {}); }); // Setup instance @@ -160,7 +189,7 @@ const vimeo = { // Set seeking state and trigger event media.seeking = true; - utils.dispatchEvent.call(player, media, 'seeking'); + triggerEvent.call(player, media, 'seeking'); // If paused, mute until seek is complete Promise.resolve(restorePause && embed.setVolume(0)) @@ -187,7 +216,7 @@ const vimeo = { .setPlaybackRate(input) .then(() => { speed = input; - utils.dispatchEvent.call(player, player.media, 'ratechange'); + triggerEvent.call(player, player.media, 'ratechange'); }) .catch(error => { // Hide menu item (and menu if empty) @@ -207,7 +236,7 @@ const vimeo = { set(input) { player.embed.setVolume(input).then(() => { volume = input; - utils.dispatchEvent.call(player, player.media, 'volumechange'); + triggerEvent.call(player, player.media, 'volumechange'); }); }, }); @@ -219,11 +248,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'); + triggerEvent.call(player, player.media, 'volumechange'); }); }, }); @@ -235,7 +264,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; @@ -268,11 +297,8 @@ const vimeo = { }); // Set aspect ratio based on video size - Promise.all([ - player.embed.getVideoWidth(), - player.embed.getVideoHeight(), - ]).then(dimensions => { - const ratio = utils.getAspectRatio(dimensions[0], dimensions[1]); + Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => { + 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'); + triggerEvent.call(player, player.media, 'timeupdate'); }); // Get duration player.embed.getDuration().then(value => { player.media.duration = value; - utils.dispatchEvent.call(player, player.media, 'durationchange'); + triggerEvent.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'); + triggerEvent.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'); + triggerEvent.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'); + triggerEvent.call(player, player.media, 'timeupdate'); }); player.embed.on('progress', data => { player.media.buffered = data.percent; - utils.dispatchEvent.call(player, player.media, 'progress'); + triggerEvent.call(player, player.media, 'progress'); // Check all loaded if (parseInt(data.percent, 10) === 1) { - utils.dispatchEvent.call(player, player.media, 'canplaythrough'); + triggerEvent.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'); + triggerEvent.call(player, player.media, 'durationchange'); } }); }); player.embed.on('seeked', () => { player.media.seeking = false; - utils.dispatchEvent.call(player, player.media, 'seeked'); + triggerEvent.call(player, player.media, 'seeked'); }); player.embed.on('ended', () => { player.media.paused = true; - utils.dispatchEvent.call(player, player.media, 'ended'); + triggerEvent.call(player, player.media, 'ended'); }); player.embed.on('error', detail => { player.media.error = detail; - utils.dispatchEvent.call(player, player.media, 'error'); + triggerEvent.call(player, player.media, 'error'); }); // Rebuild UI diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index 9b067c8a..64b6fff7 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -4,64 +4,54 @@ 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 { triggerEvent } 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) { - switch (input) { - case 'hd2160': - return 2160; - - case 2160: - return 'hd2160'; - - case 'hd1440': - return 1440; - - case 1440: - return 'hd1440'; - - case 'hd1080': - return 1080; - - case 1080: - return 'hd1080'; - - case 'hd720': - return 720; - - case 720: - return 'hd720'; - - case 'large': - return 480; - - case 480: - return 'large'; - - case 'medium': - return 360; - - case 360: - return 'medium'; - - case 'small': - return 240; - - case 240: - return 'small'; - - default: - return 'default'; + const qualities = { + hd2160: 2160, + hd1440: 1440, + hd1080: 1080, + hd720: 720, + large: 480, + medium: 360, + small: 240, + tiny: 144, + }; + + const entry = Object.entries(qualities).find(entry => entry.includes(input)); + + if (entry) { + // Get the match corresponding to the input + return entry.find(value => value !== input); } + + return 'default'; } 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 +61,24 @@ function assurePlaybackState(play) { } if (this.media.paused === play) { this.media.paused = !play; - utils.dispatchEvent.call(this, this.media, play ? 'play' : 'pause'); + triggerEvent.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 +105,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 +117,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 +143,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,30 +151,36 @@ 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); + + // Get poster, if already set + const { poster } = player; - // Set poster image + // Replace media element + const container = createElement('div', { id, poster }); + player.media = replaceElement(container, player.media); + + // Id to poster wrapper 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) if (!posterSrc.includes('maxres')) { player.elements.poster.style.backgroundSize = 'cover'; } - }); + }) + .catch(() => {}); // Setup instance // https://developers.google.com/youtube/iframe_api_reference @@ -211,49 +206,26 @@ const youtube = { }, events: { 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)) { - return; + // YouTube may fire onError twice, so only handle it once + if (!player.media.error) { + const code = event.data; + // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError + const message = + { + 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.', + 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.', + 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.', + 101: 'The owner of the requested video does not allow it to be played in embedded players.', + 150: 'The owner of the requested video does not allow it to be played in embedded players.', + }[code] || 'An unknown error occured'; + + player.media.error = { code, message }; + + triggerEvent.call(player, player.media, 'error'); } - - const detail = { - code: event.data, - }; - - // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError - switch (event.data) { - case 2: - detail.message = - 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.'; - break; - - case 5: - detail.message = - 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.'; - break; - - case 100: - detail.message = - 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.'; - break; - - case 101: - case 150: - detail.message = 'The owner of the requested video does not allow it to be played in embedded players.'; - break; - - default: - detail.message = 'An unknown error occured'; - break; - } - - player.media.error = detail; - - utils.dispatchEvent.call(player, player.media, 'error'); }, onPlaybackQualityChange() { - utils.dispatchEvent.call(player, player.media, 'qualitychange', false, { + triggerEvent.call(player, player.media, 'qualitychange', false, { quality: player.media.quality, }); }, @@ -264,7 +236,7 @@ const youtube = { // Get current speed player.media.playbackRate = instance.getPlaybackRate(); - utils.dispatchEvent.call(player, player.media, 'ratechange'); + triggerEvent.call(player, player.media, 'ratechange'); }, onReady(event) { // Get the instance @@ -305,7 +277,7 @@ const youtube = { // Set seeking state and trigger event player.media.seeking = true; - utils.dispatchEvent.call(player, player.media, 'seeking'); + triggerEvent.call(player, player.media, 'seeking'); // Seek after events sent instance.seekTo(time); @@ -328,15 +300,7 @@ const youtube = { return mapQualityUnit(instance.getPlaybackQuality()); }, set(input) { - const quality = input; - - // Set via API - instance.setPlaybackQuality(mapQualityUnit(quality)); - - // Trigger request event - utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, { - quality, - }); + instance.setPlaybackQuality(mapQualityUnit(input)); }, }); @@ -349,7 +313,7 @@ const youtube = { set(input) { volume = input; instance.setVolume(volume * 100); - utils.dispatchEvent.call(player, player.media, 'volumechange'); + triggerEvent.call(player, player.media, 'volumechange'); }, }); @@ -360,10 +324,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'); + triggerEvent.call(player, player.media, 'volumechange'); }, }); @@ -389,8 +353,8 @@ const youtube = { player.media.setAttribute('tabindex', -1); } - utils.dispatchEvent.call(player, player.media, 'timeupdate'); - utils.dispatchEvent.call(player, player.media, 'durationchange'); + triggerEvent.call(player, player.media, 'timeupdate'); + triggerEvent.call(player, player.media, 'durationchange'); // Reset timer clearInterval(player.timers.buffering); @@ -402,7 +366,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'); + triggerEvent.call(player, player.media, 'progress'); } // Set last buffer point @@ -413,7 +377,7 @@ const youtube = { clearInterval(player.timers.buffering); // Trigger event - utils.dispatchEvent.call(player, player.media, 'canplaythrough'); + triggerEvent.call(player, player.media, 'canplaythrough'); } }, 200); @@ -427,15 +391,12 @@ const youtube = { // Reset timer clearInterval(player.timers.playing); - const seeked = player.media.seeking && [ - 1, - 2, - ].includes(event.data); + const seeked = player.media.seeking && [1, 2].includes(event.data); if (seeked) { // Unset seeking and fire seeked event player.media.seeking = false; - utils.dispatchEvent.call(player, player.media, 'seeked'); + triggerEvent.call(player, player.media, 'seeked'); } // Handle events @@ -448,11 +409,11 @@ const youtube = { switch (event.data) { case -1: // Update scrubber - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + triggerEvent.call(player, player.media, 'timeupdate'); // Get loaded % from YouTube player.media.buffered = instance.getVideoLoadedFraction(); - utils.dispatchEvent.call(player, player.media, 'progress'); + triggerEvent.call(player, player.media, 'progress'); break; @@ -465,7 +426,7 @@ const youtube = { instance.stopVideo(); instance.playVideo(); } else { - utils.dispatchEvent.call(player, player.media, 'ended'); + triggerEvent.call(player, player.media, 'ended'); } break; @@ -477,11 +438,11 @@ const youtube = { } else { assurePlaybackState.call(player, true); - utils.dispatchEvent.call(player, player.media, 'playing'); + triggerEvent.call(player, player.media, 'playing'); // Poll to get playback progress player.timers.playing = setInterval(() => { - utils.dispatchEvent.call(player, player.media, 'timeupdate'); + triggerEvent.call(player, player.media, 'timeupdate'); }, 50); // Check duration again due to YouTube bug @@ -489,11 +450,14 @@ 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'); + triggerEvent.call(player, player.media, 'durationchange'); } // Get quality - controls.setQualityMenu.call(player, mapQualityUnits(instance.getAvailableQualityLevels())); + controls.setQualityMenu.call( + player, + mapQualityUnits(instance.getAvailableQualityLevels()), + ); } break; @@ -511,7 +475,7 @@ const youtube = { break; } - utils.dispatchEvent.call(player, player.elements.container, 'statechange', false, { + triggerEvent.call(player, player.elements.container, 'statechange', false, { code: event.data, }); }, diff --git a/src/js/plyr.js b/src/js/plyr.js index 5885f7c0..93fccbfa 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, wrap } from './utils/elements'; +import { off, on, once, triggerEvent, unbindListeners } from './utils/events'; +import is from './utils/is'; +import loadSprite from './utils/loadSprite'; +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,20 @@ class Plyr { this.elements.container.className = ''; // Get attributes from URL and set config - params = utils.getUrlParams(url); - if (!utils.is.empty(params)) { - const truthy = [ - '1', - 'true', - ]; - - if (truthy.includes(params.autoplay)) { + if (url.searchParams.length) { + const truthy = ['1', 'true']; + + 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 +198,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; } @@ -245,6 +246,8 @@ class Plyr { return; } + this.eventListeners = []; + // Create listeners this.listeners = new Listeners(this); @@ -255,9 +258,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); } // Add style hook @@ -268,7 +271,7 @@ class Plyr { // Listen for events if debugging if (this.config.debug) { - utils.on(this.elements.container, this.config.events.join(' '), event => { + on.call(this, this.elements.container, this.config.events.join(' '), event => { this.debug.log(`event: ${event.type}`); }); } @@ -327,7 +330,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; } @@ -339,7 +342,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; } @@ -380,7 +383,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(); @@ -396,7 +399,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(); } } @@ -413,7 +416,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); } /** @@ -421,7 +424,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); } /** @@ -435,7 +438,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; @@ -458,7 +461,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; } @@ -502,17 +505,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); } @@ -532,7 +535,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; } } @@ -550,7 +553,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); } /** @@ -559,7 +562,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); } /** @@ -570,12 +573,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; } @@ -621,15 +624,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; } @@ -666,36 +669,31 @@ class Plyr { * @param {number} input - Quality level */ set quality(input) { - let quality = null; - - if (!utils.is.empty(input)) { - quality = Number(input); - } - - if (!utils.is.number(quality)) { - quality = this.storage.get('quality'); - } - - if (!utils.is.number(quality)) { - quality = this.config.quality.selected; - } - - if (!utils.is.number(quality)) { - quality = this.config.quality.default; - } + const config = this.config.quality; + const options = this.options.quality; - if (!this.options.quality.length) { + if (!options.length) { return; } - 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; + let quality = [ + !is.empty(input) && Number(input), + this.storage.get('quality'), + config.selected, + config.default, + ].find(is.number); + + if (!options.includes(quality)) { + const value = closest(options, quality); + this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`); + quality = value; } + // Trigger request event + triggerEvent.call(this, this.media, 'qualityrequested', false, { quality }); + // Update config - this.config.quality.selected = quality; + config.selected = quality; // Set quality this.media.quality = quality; @@ -714,7 +712,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; @@ -794,7 +792,7 @@ class Plyr { return; } - ui.setPoster.call(this, input); + ui.setPoster.call(this, input, false).catch(() => {}); } /** @@ -813,7 +811,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; } @@ -829,41 +827,23 @@ class Plyr { * @param {boolean} input - Whether to enable captions */ toggleCaptions(input) { - // If there's no full support - if (!this.supported.ui) { - return; + captions.toggle.call(this, input, false); } - // 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); - - // Toggle state - this.elements.buttons.captions.pressed = active; - - // Add class hook - utils.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'); - } - } - /** * Set the caption track by index * @param {number} - Caption index */ set currentTrack(input) { - captions.set.call(this, input); + captions.set.call(this, input, false); } /** * Get the current caption track index (-1 if disabled) */ get currentTrack() { - const { active, currentTrack } = this.captions; - return active ? currentTrack : -1; + const { toggled, currentTrack } = this.captions; + return toggled ? currentTrack : -1; } /** @@ -872,7 +852,7 @@ class Plyr { * @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc) */ set language(input) { - captions.setLanguage.call(this, input); + captions.setLanguage.call(this, input, false); } /** @@ -899,7 +879,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); @@ -935,22 +915,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); + triggerEvent.call(this, this.media, eventName); } return !hiding; } @@ -963,16 +943,23 @@ class Plyr { * @param {function} callback - Callback for when event occurs */ on(event, callback) { - utils.on(this.elements.container, event, callback); + on.call(this, this.elements.container, event, callback); + } + /** + * Add event listeners once + * @param {string} event - Event type + * @param {function} callback - Callback for when event occurs + */ + once(event, callback) { + once.call(this, this.elements.container, event, callback); } - /** * Remove event listeners * @param {string} event - Event type * @param {function} callback - Callback for when event occurs */ off(event, callback) { - utils.off(this.elements.container, event, callback); + off(this.elements.container, event, callback); } /** @@ -998,10 +985,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; @@ -1011,21 +998,21 @@ class Plyr { } // Callback - if (utils.is.function(callback)) { + if (is.function(callback)) { callback(); } } else { // Unbind listeners - this.listeners.clear(); + unbindListeners.call(this); // 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); + triggerEvent.call(this, this.elements.original, 'destroyed', true); // Callback - if (utils.is.function(callback)) { + if (is.function(callback)) { callback.call(this.elements.original); } @@ -1043,10 +1030,8 @@ class Plyr { // Stop playback this.stop(); - // Type specific stuff - switch (`${this.provider}:${this.type}`) { - case 'html5:video': - case 'html5:audio': + // Provider specific stuff + if (this.isHTML5) { // Clear timeout clearTimeout(this.timers.loading); @@ -1055,25 +1040,19 @@ class Plyr { // Clean up done(); - - break; - - case 'youtube:video': + } else if (this.isYouTube) { // Clear timers clearInterval(this.timers.buffering); 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(); } // Clean up done(); - - break; - - case 'vimeo:video': + } else if (this.isVimeo) { // Destroy Vimeo API // then clean up (wait, to prevent postmessage errors) if (this.embed !== null) { @@ -1082,11 +1061,6 @@ class Plyr { // Vimeo does not always return setTimeout(done, 200); - - break; - - default: - break; } } @@ -1114,7 +1088,7 @@ class Plyr { * @param {string} [id] - Unique ID */ static loadSprite(url, id) { - return utils.loadSprite(url, id); + return loadSprite(url, id); } /** @@ -1125,15 +1099,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; } @@ -1141,6 +1115,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..8c9fdf44 100644 --- a/src/js/source.js +++ b/src/js/source.js @@ -2,23 +2,25 @@ // 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'; +import { getDeep } from './utils/objects'; 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 +28,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 (!getDeep(input, 'sources.length')) { this.debug.warn('Invalid source format'); return; } @@ -42,47 +44,34 @@ 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; - - // Check for support - this.supported = support.check(this.type, this.provider, this.config.playsinline); - - // Create new markup - switch (`${this.provider}:${this.type}`) { - case 'html5:video': - this.media = utils.createElement('video'); - break; - - case 'html5:audio': - this.media = utils.createElement('audio'); - break; - - case 'youtube:video': - case 'vimeo:video': - this.media = utils.createElement('div', { - src: input.sources[0].src, - }); - break; - - default: - break; - } + const { sources, type } = input; + const [{ provider = providers.html5, src }] = sources; + const tagName = provider === 'html5' ? type : 'div'; + const attributes = provider === 'html5' ? {} : { src }; + + Object.assign(this, { + provider, + type, + // Check for support + supported: support.check(type, provider, this.config.playsinline), + // Create new element + media: createElement(tagName, attributes), + }); // Inject the new element 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 +83,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) { @@ -113,7 +102,7 @@ const source = { // Set new sources for html5 if (this.isHTML5) { - source.insertElements.call(this, 'source', input.sources); + source.insertElements.call(this, 'source', sources); } // Set video title 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..6395293f 100644 --- a/src/js/support.js +++ b/src/js/support.js @@ -2,7 +2,19 @@ // 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'; + +// Default codecs for checking mimetype support +const defaultCodecs = { + 'audio/ogg': 'vorbis', + 'audio/wav': '1', + 'video/webm': 'vp8, vorbis', + 'video/mp4': 'avc1.42E01E, mp4a.40.2', + 'video/ogg': 'theora', +}; // Check for feature support const support = { @@ -13,32 +25,9 @@ const support = { // Check for support // Basic functionality vs full UI check(type, provider, playsinline) { - let api = false; - let ui = false; - const browser = utils.getBrowser(); const canPlayInline = browser.isIPhone && playsinline && support.playsinline; - - switch (`${provider}:${type}`) { - case 'html5:video': - api = support.video; - ui = api && support.rangeInput && (!browser.isIPhone || canPlayInline); - break; - - case 'html5:audio': - api = support.audio; - ui = api && support.rangeInput; - break; - - case 'youtube:video': - case 'vimeo:video': - api = true; - ui = support.rangeInput && (!browser.isIPhone || canPlayInline); - break; - - default: - api = support.audio && support.video; - ui = api && support.rangeInput; - } + const api = support[type] || provider !== 'html5'; + const ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline); return { api, @@ -48,14 +37,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/ @@ -64,83 +50,34 @@ const support = { // Check for mime type support against a player instance // Credits: http://diveintohtml5.info/everything.html // Related: http://www.leanbackplayer.com/test/h5mt.html - mime(type) { - const { media } = this; + mime(inputType) { + const [mediaType] = inputType.split('/'); + if (!this.isHTML5 || mediaType !== this.type) { + return false; + } + + let type; + if (inputType && inputType.includes('codecs=')) { + // Use input directly + type = inputType; + } else if (inputType === 'audio/mpeg') { + // Skip codec + type = 'audio/mpeg;'; + } else if (inputType in defaultCodecs) { + // Use codec + type = `${inputType}; codecs="${defaultCodecs[inputType]}"`; + } try { - // Bail if no checking function - if (!this.isHTML5 || !utils.is.function(media.canPlayType)) { - return false; - } - - // Check directly if codecs specified - if (type.includes('codecs=')) { - return media.canPlayType(type).replace(/no/, ''); - } - - // Type specific checks - if (this.isVideo) { - switch (type) { - case 'video/webm': - return media.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''); - - case 'video/mp4': - return media.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''); - - case 'video/ogg': - return media.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''); - - default: - return false; - } - } else if (this.isAudio) { - switch (type) { - case 'audio/mpeg': - return media.canPlayType('audio/mpeg;').replace(/no/, ''); - - case 'audio/ogg': - return media.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''); - - case 'audio/wav': - return media.canPlayType('audio/wav; codecs="1"').replace(/no/, ''); - - default: - return false; - } - } - } catch (e) { + return Boolean(type && this.media.canPlayType(type).replace(/no/, '')); + } catch (err) { return false; } - - // If we got this far, we're stuffed - return false; }, // 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 +90,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 979d8341..b77ee131 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 } from './utils/elements'; +import { ready, triggerEvent } 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,31 +86,35 @@ 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'); + triggerEvent.call(this, this.media, 'ready'); }, 0); // Set the title ui.setTitle.call(this); // Assure the poster image is set, if the property was added before the element was created - if (this.poster && this.elements.poster && !this.elements.poster.style.backgroundImage) { - ui.setPoster.call(this, this.poster); + if (this.poster) { + ui.setPoster.call(this, this.poster, false).catch(() => {}); } // Manually set the duration if user has overridden it. @@ -125,12 +130,12 @@ 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}`; } // 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); }); @@ -139,14 +144,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)); @@ -155,44 +160,58 @@ 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) - setPoster(poster) { - // Set property regardless of validity - this.media.setAttribute('poster', poster); - - // Bail if element is missing - if (!utils.is.element(this.elements.poster)) { - return Promise.reject(); + // Used internally for the poster setter, with the passive option forced to false + setPoster(poster, passive = true) { + // Don't override if call is passive + if (passive && this.poster) { + return Promise.reject(new Error('Poster already set')); } - // 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; - }); - - // 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)); + // Set property synchronously to respect the call order + this.media.setAttribute('poster', poster); - // Return the promise so the caller can use it as well - return loadPromise; + // Wait until ui is ready + return ( + ready + .call(this) + // Load image + .then(() => loadImage(poster)) + .catch(err => { + // Hide poster on error unless it's been set by another call + if (poster === this.poster) { + ui.togglePoster.call(this, false); + } + // Rethrow + throw err; + }) + .then(() => { + // Prevent race conditions + if (poster !== this.poster) { + throw new Error('setPoster cancelled by later call to setPoster'); + } + }) + .then(() => { + 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; + }) + ); }, // 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 state Array.from(this.elements.buttons.play).forEach(target => { @@ -200,7 +219,7 @@ const ui = { }); // Only update controls on non timeupdate events - if (utils.is.event(event) && event.type === 'timeupdate') { + if (is.event(event) && event.type === 'timeupdate') { return; } @@ -210,10 +229,7 @@ const ui = { // Check if media is loading checkLoading(event) { - this.loading = [ - 'stalled', - 'waiting', - ].includes(event.type); + this.loading = ['stalled', 'waiting'].includes(event.type); // Clear timer clearTimeout(this.timers.loading); @@ -221,7 +237,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 d2a37706..00000000 --- a/src/js/utils.js +++ /dev/null @@ -1,853 +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); - }, - - // 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..69e4d46c --- /dev/null +++ b/src/js/utils/elements.js @@ -0,0 +1,285 @@ +// ========================================================================== +// Element utils +// ========================================================================== + +import { toggleListener } from './events'; +import is from './is'; + +// Wrap an element +export function wrap(elements, wrapper) { + // Convert `elements` to an array, if necessary. + const targets = elements.length ? elements : [elements]; + + // Loops backwards to prevent having to clone the wrapper on the + // first element (see `child` below). + Array.from(targets) + .reverse() + .forEach((element, index) => { + const child = index > 0 ? wrapper.cloneNode(true) : wrapper; + + // Cache the current parent and sibling. + const parent = element.parentNode; + const sibling = element.nextSibling; + + // Wrap the element (is automatically removed from its current + // parent). + child.appendChild(element); + + // If the element had a sibling, insert the wrapper before + // the sibling to maintain the HTML structure; otherwise, just + // append it to the parent. + if (sibling) { + parent.insertBefore(child, sibling); + } else { + parent.appendChild(child); + } + }); +} + +// Set attributes +export function setAttributes(element, attributes) { + if (!is.element(element) || is.empty(attributes)) { + return; + } + + // Assume null and undefined attributes should be left out, + // Setting them would otherwise convert them to "null" and "undefined" + Object.entries(attributes) + .filter(([, value]) => !is.nullOrUndefined(value)) + .forEach(([key, value]) => element.setAttribute(key, value)); +} + +// Create a DocumentFragment +export function createElement(type, attributes, text) { + // Create a new <element> + const element = document.createElement(type); + + // Set all passed attributes + if (is.object(attributes)) { + setAttributes(element, attributes); + } + + // Add text node + if (is.string(text)) { + element.innerText = text; + } + + // Return built element + return element; +} + +// Inaert an element after another +export function insertAfter(element, target) { + target.parentNode.insertBefore(element, target.nextSibling); +} + +// Insert a DocumentFragment +export function insertElement(type, parent, attributes, text) { + // Inject the new <element> + parent.appendChild(createElement(type, attributes, text)); +} + +// Remove element(s) +export function removeElement(element) { + if (is.nodeList(element) || is.array(element)) { + Array.from(element).forEach(removeElement); + return; + } + + if (!is.element(element) || !is.element(element.parentNode)) { + return; + } + + element.parentNode.removeChild(element); +} + +// Remove all child elements +export function emptyElement(element) { + let { length } = element.childNodes; + + while (length > 0) { + element.removeChild(element.lastChild); + length -= 1; + } +} + +// Replace element +export function replaceElement(newChild, oldChild) { + if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) { + return null; + } + + oldChild.parentNode.replaceChild(newChild, oldChild); + + return newChild; +} + +// Get an attribute object from a string selector +export function getAttributesFromSelector(sel, existingAttributes) { + // For example: + // '.test' to { class: 'test' } + // '#test' to { id: 'test' } + // '[data-test="test"]' to { 'data-test': 'test' } + + if (!is.string(sel) || is.empty(sel)) { + return {}; + } + + const attributes = {}; + const existing = existingAttributes; + + sel.split(',').forEach(s => { + // Remove whitespace + const selector = s.trim(); + const className = selector.replace('.', ''); + const stripped = selector.replace(/[[\]]/g, ''); + + // Get the parts and value + const parts = stripped.split('='); + const key = parts[0]; + const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; + + // Get the first character + const start = selector.charAt(0); + + switch (start) { + case '.': + // Add to existing classname + if (is.object(existing) && is.string(existing.class)) { + existing.class += ` ${className}`; + } + + attributes.class = className; + break; + + case '#': + // ID selector + attributes.id = selector.replace('#', ''); + break; + + case '[': + // Attribute selector + attributes[key] = value; + + break; + + default: + break; + } + }); + + return attributes; +} + +// Toggle hidden +export function toggleHidden(element, hidden) { + if (!is.element(element)) { + return; + } + + let hide = hidden; + + if (!is.boolean(hide)) { + hide = !element.hasAttribute('hidden'); + } + + if (hide) { + element.setAttribute('hidden', ''); + } else { + element.removeAttribute('hidden'); + } +} + +// Mirror Element.classList.toggle, with IE compatibility for "force" argument +export function toggleClass(element, className, force) { + if (is.element(element)) { + let method = 'toggle'; + if (typeof force !== 'undefined') { + method = force ? 'add' : 'remove'; + } + + element.classList[method](className); + return element.classList.contains(className); + } + + return null; +} + +// Has class name +export function hasClass(element, className) { + return is.element(element) && element.classList.contains(className); +} + +// Element matches selector +export function matches(element, selector) { + const prototype = { Element }; + + function match() { + return Array.from(document.querySelectorAll(selector)).includes(this); + } + + const matches = + prototype.matches || + prototype.webkitMatchesSelector || + prototype.mozMatchesSelector || + prototype.msMatchesSelector || + match; + + return matches.call(element, selector); +} + +// Find all elements +export function getElements(selector) { + return this.elements.container.querySelectorAll(selector); +} + +// Find a single element +export function getElement(selector) { + return this.elements.container.querySelector(selector); +} + +// Get the focused element +export function getFocusElement() { + let focused = document.activeElement; + + if (!focused || focused === document.body) { + focused = null; + } else { + focused = document.querySelector(':focus'); + } + + return focused; +} + +// Trap focus inside container +export function trapFocus(element = null, toggle = false) { + if (!is.element(element)) { + return; + } + + const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + const trap = event => { + // Bail if not tab key or not fullscreen + if (event.key !== 'Tab' || event.keyCode !== 9) { + return; + } + + // Get the current focused element + const focused = getFocusElement(); + + if (focused === last && !event.shiftKey) { + // Move focus to first element that can be tabbed if Shift isn't used + first.focus(); + event.preventDefault(); + } else if (focused === first && event.shiftKey) { + // Move focus to last element that can be tabbed if Shift is used + last.focus(); + event.preventDefault(); + } + }; + + toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false); +} diff --git a/src/js/utils/events.js b/src/js/utils/events.js new file mode 100644 index 00000000..9f734f04 --- /dev/null +++ b/src/js/utils/events.js @@ -0,0 +1,120 @@ +// ========================================================================== +// Event utils +// ========================================================================== + +import is from './is'; + +// Check for passive event listener support +// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md +// https://www.youtube.com/watch?v=NPM6172J22g +const supportsPassiveListeners = (() => { + // Test via a getter in the options object to see if the passive property is accessed + let supported = false; + try { + const options = Object.defineProperty({}, 'passive', { + get() { + supported = true; + return null; + }, + }); + window.addEventListener('test', null, options); + window.removeEventListener('test', null, options); + } catch (e) { + // Do nothing + } + + return supported; +})(); + +// Toggle event listener +export function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) { + // Bail if no element, event, or callback + if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) { + return; + } + + // Allow multiple events + const events = event.split(' '); + + // Build options + // Default to just the capture boolean for browsers with no passive listener support + let options = capture; + + // If passive events listeners are supported + if (supportsPassiveListeners) { + options = { + // Whether the listener can be passive (i.e. default never prevented) + passive, + // Whether the listener is a capturing listener or not + capture, + }; + } + + // If a single node is passed, bind the event listener + events.forEach(type => { + if (this && this.eventListeners && toggle) { + // Cache event listener + this.eventListeners.push({ element, type, callback, options }); + } + + element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); +} + +// Bind event handler +export function on(element, events = '', callback, passive = true, capture = false) { + toggleListener.call(this, element, events, callback, true, passive, capture); +} + +// Unbind event handler +export function off(element, events = '', callback, passive = true, capture = false) { + toggleListener.call(this, element, events, callback, false, passive, capture); +} + +// Bind once-only event handler +export function once(element, events = '', callback, passive = true, capture = false) { + function onceCallback(...args) { + off(element, events, onceCallback, passive, capture); + callback.apply(this, args); + } + + toggleListener.call(this, element, events, onceCallback, true, passive, capture); +} + +// Trigger event +export function triggerEvent(element, type = '', bubbles = false, detail = {}) { + // Bail if no element + if (!is.element(element) || is.empty(type)) { + return; + } + + // Create and dispatch the event + const event = new CustomEvent(type, { + bubbles, + detail: Object.assign({}, detail, { + plyr: this, + }), + }); + + // Dispatch the event + element.dispatchEvent(event); +} + +// Unbind all cached event listeners +export function unbindListeners() { + if (this && this.eventListeners) { + this.eventListeners.forEach(item => { + const { element, type, callback, options } = item; + element.removeEventListener(type, callback, options); + }); + + this.eventListeners = []; + } +} + +// Run method when / if player is ready +export function ready() { + return new Promise( + resolve => (this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)), + ).then(() => {}); +} diff --git a/src/js/utils/fetch.js b/src/js/utils/fetch.js new file mode 100644 index 00000000..1e506cd0 --- /dev/null +++ b/src/js/utils/fetch.js @@ -0,0 +1,42 @@ +// ========================================================================== +// Fetch wrapper +// Using XHR to avoid issues with older browsers +// ========================================================================== + +export default function fetch(url, responseType = 'text') { + return new Promise((resolve, reject) => { + try { + const request = new XMLHttpRequest(); + + // Check for CORS support + if (!('withCredentials' in request)) { + return; + } + + request.addEventListener('load', () => { + if (responseType === 'text') { + try { + resolve(JSON.parse(request.responseText)); + } catch (e) { + resolve(request.responseText); + } + } else { + resolve(request.response); + } + }); + + request.addEventListener('error', () => { + throw new Error(request.statusText); + }); + + request.open('GET', url, true); + + // Set the required response type + request.responseType = responseType; + + request.send(); + } catch (e) { + reject(e); + } + }); +} diff --git a/src/js/utils/is.js b/src/js/utils/is.js new file mode 100644 index 00000000..cb2c07c6 --- /dev/null +++ b/src/js/utils/is.js @@ -0,0 +1,67 @@ +// ========================================================================== +// Type checking utils +// ========================================================================== + +const getConstructor = input => (input !== null && typeof input !== 'undefined' ? input.constructor : null); + +const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor); + +const is = { + object(input) { + return getConstructor(input) === Object; + }, + number(input) { + return getConstructor(input) === Number && !Number.isNaN(input); + }, + string(input) { + return getConstructor(input) === String; + }, + boolean(input) { + return getConstructor(input) === Boolean; + }, + function(input) { + return getConstructor(input) === Function; + }, + array(input) { + return !is.nullOrUndefined(input) && Array.isArray(input); + }, + weakMap(input) { + return instanceOf(input, WeakMap); + }, + nodeList(input) { + return instanceOf(input, NodeList); + }, + element(input) { + return instanceOf(input, Element); + }, + textNode(input) { + return getConstructor(input) === Text; + }, + event(input) { + return instanceOf(input, Event); + }, + cue(input) { + return instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue); + }, + track(input) { + return instanceOf(input, TextTrack) || (!is.nullOrUndefined(input) && is.string(input.kind)); + }, + url(input) { + return ( + !is.nullOrUndefined(input) && + /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input) + ); + }, + nullOrUndefined(input) { + return input === null || typeof input === 'undefined'; + }, + empty(input) { + return ( + is.nullOrUndefined(input) || + ((is.string(input) || is.array(input) || is.nodeList(input)) && !input.length) || + (is.object(input) && !Object.keys(input).length) + ); + }, +}; + +export default is; diff --git a/src/js/utils/loadImage.js b/src/js/utils/loadImage.js new file mode 100644 index 00000000..8acd2496 --- /dev/null +++ b/src/js/utils/loadImage.js @@ -0,0 +1,19 @@ +// ========================================================================== +// Load image avoiding xhr/fetch CORS issues +// Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded +// By default it checks if it is at least 1px, but you can add a second argument to change this +// ========================================================================== + +export default function loadImage(src, minWidth = 1) { + return new Promise((resolve, reject) => { + const image = new Image(); + + const handler = () => { + delete image.onload; + delete image.onerror; + (image.naturalWidth >= minWidth ? resolve : reject)(image); + }; + + Object.assign(image, { onload: handler, onerror: handler, src }); + }); +} diff --git a/src/js/utils/loadScript.js b/src/js/utils/loadScript.js new file mode 100644 index 00000000..81ae36f4 --- /dev/null +++ b/src/js/utils/loadScript.js @@ -0,0 +1,14 @@ +// ========================================================================== +// Load an external script +// ========================================================================== + +import loadjs from 'loadjs'; + +export default function loadScript(url) { + return new Promise((resolve, reject) => { + loadjs(url, { + success: resolve, + error: reject, + }); + }); +} diff --git a/src/js/utils/loadSprite.js b/src/js/utils/loadSprite.js new file mode 100644 index 00000000..dbb00cf2 --- /dev/null +++ b/src/js/utils/loadSprite.js @@ -0,0 +1,75 @@ +// ========================================================================== +// Sprite loader +// ========================================================================== + +import Storage from './../storage'; +import is from './is'; + +// Load an external SVG sprite +export default function loadSprite(url, id) { + if (!is.string(url)) { + return; + } + + const prefix = 'cache'; + const hasId = is.string(id); + let isCached = false; + + const exists = () => document.getElementById(id) !== null; + + const update = (container, data) => { + container.innerHTML = data; + + // Check again incase of race condition + if (hasId && exists()) { + return; + } + + // Inject the SVG to the body + document.body.insertAdjacentElement('afterbegin', container); + }; + + // Only load once if ID set + if (!hasId || !exists()) { + const useStorage = Storage.supported; + + // Create container + const container = document.createElement('div'); + container.setAttribute('hidden', ''); + + if (hasId) { + container.setAttribute('id', id); + } + + // Check in cache + if (useStorage) { + const cached = window.localStorage.getItem(`${prefix}-${id}`); + isCached = cached !== null; + + if (isCached) { + const data = JSON.parse(cached); + update(container, data.content); + } + } + + // Get the sprite + fetch(url) + .then(result => { + if (is.empty(result)) { + return; + } + + if (useStorage) { + window.localStorage.setItem( + `${prefix}-${id}`, + JSON.stringify({ + content: result, + }), + ); + } + + update(container, result); + }) + .catch(() => {}); + } +} diff --git a/src/js/utils/objects.js b/src/js/utils/objects.js new file mode 100644 index 00000000..225bb459 --- /dev/null +++ b/src/js/utils/objects.js @@ -0,0 +1,42 @@ +// ========================================================================== +// Object utils +// ========================================================================== + +import is from './is'; + +// Clone nested objects +export function cloneDeep(object) { + return JSON.parse(JSON.stringify(object)); +} + +// Get a nested value in an object +export function getDeep(object, path) { + return path.split('.').reduce((obj, key) => obj && obj[key], object); +} + +// Deep extend destination object with N more objects +export function extend(target = {}, ...sources) { + if (!sources.length) { + return target; + } + + const source = sources.shift(); + + if (!is.object(source)) { + return target; + } + + Object.keys(source).forEach(key => { + if (is.object(source[key])) { + if (!Object.keys(target).includes(key)) { + Object.assign(target, { [key]: {} }); + } + + extend(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + }); + + return extend(target, ...sources); +} diff --git a/src/js/utils/strings.js b/src/js/utils/strings.js new file mode 100644 index 00000000..c872498c --- /dev/null +++ b/src/js/utils/strings.js @@ -0,0 +1,85 @@ +// ========================================================================== +// String utils +// ========================================================================== + +import is from './is'; + +// Generate a random ID +export function generateId(prefix) { + return `${prefix}-${Math.floor(Math.random() * 10000)}`; +} + +// Format string +export function format(input, ...args) { + if (is.empty(input)) { + return input; + } + + return input.toString().replace(/{(\d+)}/g, (match, i) => args[i].toString()); +} + +// Get percentage +export function getPercentage(current, max) { + if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { + return 0; + } + + return (current / max * 100).toFixed(2); +} + +// Replace all occurances of a string in a string +export function replaceAll(input = '', find = '', replace = '') { + return input.replace( + new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), + replace.toString(), + ); +} + +// Convert to title case +export function toTitleCase(input = '') { + return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase()); +} + +// Convert string to pascalCase +export function toPascalCase(input = '') { + let string = input.toString(); + + // Convert kebab case + string = replaceAll(string, '-', ' '); + + // Convert snake case + string = replaceAll(string, '_', ' '); + + // Convert to title case + string = toTitleCase(string); + + // Convert to pascal case + return replaceAll(string, ' ', ''); +} + +// Convert string to pascalCase +export function toCamelCase(input = '') { + let string = input.toString(); + + // Convert to pascal case + string = toPascalCase(string); + + // Convert first character to lowercase + return string.charAt(0).toLowerCase() + string.slice(1); +} + +// Remove HTML from a string +export function stripHTML(source) { + const fragment = document.createDocumentFragment(); + const element = document.createElement('div'); + fragment.appendChild(element); + element.innerHTML = source; + return fragment.firstChild.innerText; +} + +// Like outerHTML, but also works for DocumentFragment +export function getHTML(element) { + const wrapper = document.createElement('div'); + wrapper.appendChild(element); + return wrapper.innerHTML; +} diff --git a/src/js/utils/time.js b/src/js/utils/time.js new file mode 100644 index 00000000..7c9860fd --- /dev/null +++ b/src/js/utils/time.js @@ -0,0 +1,36 @@ +// ========================================================================== +// Time utils +// ========================================================================== + +import is from './is'; + +// Time helpers +export const getHours = value => parseInt((value / 60 / 60) % 60, 10); +export const getMinutes = value => parseInt((value / 60) % 60, 10); +export const getSeconds = value => parseInt(value % 60, 10); + +// Format time to UI friendly string +export function formatTime(time = 0, displayHours = false, inverted = false) { + // Bail if the value isn't a number + if (!is.number(time)) { + return formatTime(null, displayHours, inverted); + } + + // Format time component to add leading zero + const format = value => `0${value}`.slice(-2); + + // Breakdown to hours, mins, secs + let hours = getHours(time); + const mins = getMinutes(time); + const secs = getSeconds(time); + + // Do we need to display hours? + if (displayHours || hours > 0) { + hours = `${hours}:`; + } else { + hours = ''; + } + + // Render + return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; +} diff --git a/src/js/utils/urls.js b/src/js/utils/urls.js new file mode 100644 index 00000000..3ebe622e --- /dev/null +++ b/src/js/utils/urls.js @@ -0,0 +1,39 @@ +// ========================================================================== +// URL utils +// ========================================================================== + +import is from './is'; + +/** + * Parse a string to a URL object + * @param {string} input - the URL to be parsed + * @param {boolean} safe - failsafe parsing + */ +export function parseUrl(input, safe = true) { + let url = input; + + if (safe) { + const parser = document.createElement('a'); + parser.href = url; + url = parser.href; + } + + try { + return new URL(url); + } catch (e) { + return null; + } +} + +// Convert object to URLSearchParams +export function buildUrlParams(input) { + const params = new URLSearchParams(); + + if (is.object(input)) { + Object.entries(input).forEach(([key, value]) => { + params.set(key, value); + }); + } + + return params; +} |