diff options
Diffstat (limited to 'src/js')
-rw-r--r-- | src/js/captions.js | 2 | ||||
-rw-r--r-- | src/js/controls.js | 182 | ||||
-rw-r--r-- | src/js/defaults.js | 1 | ||||
-rw-r--r-- | src/js/listeners.js | 106 | ||||
-rw-r--r-- | src/js/plyr.js | 126 | ||||
-rw-r--r-- | src/js/support.js | 1 | ||||
-rw-r--r-- | src/js/ui.js | 173 | ||||
-rw-r--r-- | src/js/utils.js | 30 |
8 files changed, 287 insertions, 334 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index e0692dcf..df717351 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -236,7 +236,7 @@ const captions = { // Set the span content if (utils.is.string(caption)) { - content.textContent = caption.trim(); + content.innerText = caption.trim(); } else { content.appendChild(caption); } diff --git a/src/js/controls.js b/src/js/controls.js index ec64977e..8a160864 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -6,34 +6,13 @@ import captions from './captions'; import html5 from './html5'; import i18n from './i18n'; import support from './support'; -import ui from './ui'; import utils from './utils'; // Sniff out the browser const browser = utils.getBrowser(); 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; - - // Needs to be a valid <input type='range'> - if (!utils.is.element(range) || range.getAttribute('type') !== 'range') { - return; - } - - // Set aria value for https://github.com/sampotts/plyr/issues/905 - range.setAttribute('aria-valuenow', range.value); - - // WebKit only - if (!browser.isWebkit) { - return; - } - // Set CSS custom property - range.style.setProperty('--value', `${range.value / range.max * 100}%`); - }, // Get icon URL getIconUrl() { @@ -373,7 +352,7 @@ const controls = { break; } - progress.textContent = `% ${suffix.toLowerCase()}`; + progress.innerText = `% ${suffix.toLowerCase()}`; } this.elements.display[type] = progress; @@ -429,6 +408,123 @@ const controls = { list.appendChild(item); }, + // 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)) { + return; + } + + // Always display hours if duration is over an hour + const forceHours = utils.getHours(this.duration) > 0; + + // eslint-disable-next-line no-param-reassign + target.innerText = utils.formatTime(time, forceHours, inverted); + }, + + // Update volume UI and storage + updateVolume() { + if (!this.supported.ui) { + return; + } + + // Update range + if (utils.is.element(this.elements.inputs.volume)) { + controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); + } + + // Update mute state + if (utils.is.element(this.elements.buttons.mute)) { + utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); + } + }, + + // Update seek value and lower fill + setRange(target, value = 0) { + if (!utils.is.element(target)) { + return; + } + + // eslint-disable-next-line + target.value = value; + + // Webkit range fill + controls.updateRangeFill.call(this, target); + }, + + // Update <progress> elements + updateProgress(event) { + if (!this.supported.ui || !utils.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; + + // Update value and label + if (utils.is.element(progress)) { + progress.value = value; + + // Update text label inside + const label = progress.getElementsByTagName('span')[0]; + if (utils.is.element(label)) { + label.childNodes[0].nodeValue = value; + } + } + }; + + if (event) { + switch (event.type) { + // Video playing + case 'timeupdate': + case 'seeking': + value = utils.getPercentage(this.currentTime, this.duration); + + // Set seek range value only if it's a 'natural' time event + if (event.type === 'timeupdate') { + controls.setRange.call(this, this.elements.inputs.seek, value); + } + + break; + + // Check buffer status + case 'playing': + case 'progress': + setProgress(this.elements.display.buffer, this.buffered * 100); + + break; + + default: + break; + } + } + }, + + // Webkit polyfill for lower fill range + updateRangeFill(target) { + // Get range from event if event passed + const range = utils.is.event(target) ? target.target : target; + + // Needs to be a valid <input type='range'> + if (!utils.is.element(range) || range.getAttribute('type') !== 'range') { + return; + } + + // Set aria value for https://github.com/sampotts/plyr/issues/905 + range.setAttribute('aria-valuenow', range.value); + + // WebKit only + if (!browser.isWebkit) { + return; + } + + // Set CSS custom property + range.style.setProperty('--value', `${range.value / range.max * 100}%`); + }, + // Update hover tooltip for seeking updateSeekTooltip(event) { // Bail if setting not true @@ -473,7 +569,7 @@ const controls = { } // Display the time a click would seek to - ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent); + controls.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent); // Set position this.elements.display.seekTooltip.style.left = `${percent}%`; @@ -488,6 +584,46 @@ 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; + + // Duration + 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) { + return; + } + + // Playing progress + controls.updateProgress.call(this, event); + }, + + // Show the duration on metadataloaded + durationUpdate() { + if (!this.supported.ui) { + return; + } + + // If there's a spot to display duration + const hasDuration = utils.is.element(this.elements.display.duration); + + // If there's only one time display, display duration there + if (!hasDuration && this.config.displayDuration && this.paused) { + controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration); + } + + // If there's a duration element, update content + if (hasDuration) { + controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); + } + + // Update the tooltip (if visible) + controls.updateSeekTooltip.call(this); + }, + // Hide/show a tab toggleTab(setting, toggle) { utils.toggleHidden(this.elements.settings.tabs[setting], !toggle); diff --git a/src/js/defaults.js b/src/js/defaults.js index 7cc5c082..f66a7c2f 100644 --- a/src/js/defaults.js +++ b/src/js/defaults.js @@ -338,7 +338,6 @@ const defaults = { paused: 'plyr--paused', stopped: 'plyr--stopped', loading: 'plyr--loading', - error: 'plyr--has-error', hover: 'plyr--hover', tooltip: 'plyr__tooltip', cues: 'plyr__cues', diff --git a/src/js/listeners.js b/src/js/listeners.js index f4e9ade3..5dd9d93f 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -238,22 +238,45 @@ class Listeners { }, 0); }); - // Toggle controls visibility based on mouse movement - if (this.player.config.hideControls) { - // Toggle controls on mouse events and entering fullscreen - utils.on(this.player.elements.container, 'mouseenter mouseleave mousemove touchstart touchend touchmove enterfullscreen exitfullscreen', event => { - this.player.toggleControls(event); - }); - } + // 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; + + // 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); + + let delay = 0; + + 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); + }); } // Listen for media events media() { // Time change on media - utils.on(this.player.media, 'timeupdate seeking', event => ui.timeUpdate.call(this.player, event)); + utils.on(this.player.media, 'timeupdate seeking', event => controls.timeUpdate.call(this.player, event)); // Display duration - utils.on(this.player.media, 'durationchange loadeddata loadedmetadata', event => ui.durationUpdate.call(this.player, event)); + utils.on(this.player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(this.player, event)); // Check for audio tracks on load // We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point @@ -272,10 +295,10 @@ class Listeners { }); // Check for buffer progress - utils.on(this.player.media, 'progress playing', event => ui.updateProgress.call(this.player, event)); + utils.on(this.player.media, 'progress playing', event => controls.updateProgress.call(this.player, event)); // Handle volume changes - utils.on(this.player.media, 'volumechange', event => ui.updateVolume.call(this.player, event)); + utils.on(this.player.media, 'volumechange', event => controls.updateVolume.call(this.player, event)); // Handle play/pause utils.on(this.player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(this.player, event)); @@ -283,9 +306,6 @@ class Listeners { // Loading state utils.on(this.player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(this.player, event)); - // Check if media failed to load - // utils.on(this.player.media, 'play', event => ui.checkFailed.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', () => { @@ -549,7 +569,8 @@ class Listeners { } this.player.config.invertTime = !this.player.config.invertTime; - ui.timeUpdate.call(this.player); + + controls.timeUpdate.call(this.player); }); } @@ -573,26 +594,45 @@ class Listeners { // Seek tooltip on(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(this.player, event)); - // Toggle controls visibility based on mouse movement - if (this.player.config.hideControls) { - // Watch for cursor over controls so they don't hide when trying to interact - on(this.player.elements.controls, 'mouseenter mouseleave', event => { - this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter'; - }); + // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting) + on(this.player.elements.controls, 'mouseenter mouseleave', event => { + this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter'; + }); - // Watch for cursor over controls so they don't hide when trying to interact - on(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { - this.player.elements.controls.pressed = [ - 'mousedown', - 'touchstart', - ].includes(event.type); - }); + // 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); + }); - // Focus in/out on controls - on(this.player.elements.controls, 'focusin focusout', event => { - this.player.toggleControls(event); - }); - } + // Focus in/out on controls + on(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'); + + // Toggle + ui.toggleControls.call(this.player, event.type === 'focusin'); + + // If focusin, hide again after delay + if (event.type === 'focusin') { + // Restore transition + setTimeout(() => { + utils.toggleClass(elements.controls, config.classNames.noTransition, false); + }, 0); + + // Delay a little more for keyboard users + const delay = this.touch ? 3000 : 4000; + + // Clear timer + clearTimeout(timers.controls); + // Hide + timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay); + } + }); // Mouse wheel for volume on( diff --git a/src/js/plyr.js b/src/js/plyr.js index dee90dd2..4c569fec 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -55,6 +55,7 @@ class Plyr { this.config = utils.extend( {}, defaults, + Plyr.defaults, options || {}, (() => { try { @@ -967,119 +968,32 @@ class Plyr { /** * Toggle the player controls - * @param {boolean} toggle - Whether to show the controls + * @param {boolean} [toggle] - Whether to show the controls */ toggleControls(toggle) { - // We need controls of course... - if (!utils.is.element(this.elements.controls)) { - return; - } + // 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); - // Don't hide if no UI support or it's audio - if (!this.supported.ui || this.isAudio) { - return; - } + // Negate the argument if not undefined since adding the class to hides the controls + const force = typeof toggle === 'undefined' ? undefined : !toggle; - let delay = 0; - let show = toggle; - let isEnterFullscreen = false; + // Apply and get updated state + const hiding = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, force); - // Get toggle state if not set - if (!utils.is.boolean(toggle)) { - if (utils.is.event(toggle)) { - // Is the enter fullscreen event - isEnterFullscreen = toggle.type === 'enterfullscreen'; - - // Events that show the controls - const showEvents = [ - 'touchstart', - 'touchmove', - 'mouseenter', - 'mousemove', - 'focusin', - ]; - - // Events that delay hiding - const delayEvents = [ - 'touchmove', - 'touchend', - 'mousemove', - ]; - - // Whether to show controls - show = showEvents.includes(toggle.type); - - // Delay hiding on move events - if (delayEvents.includes(toggle.type)) { - delay = 2000; - } - - // Delay a little more for keyboard users - if (!this.touch && toggle.type === 'focusin') { - delay = 3000; - utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, true); - } - } else { - show = utils.hasClass(this.elements.container, this.config.classNames.hideControls); + // Close menu + if (hiding && this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) { + controls.toggleMenu.call(this, false); } - } - - // Clear timer on every call - clearTimeout(this.timers.controls); - - // If the mouse is not over the controls, set a timeout to hide them - if (show || this.paused || this.loading) { - // Check if controls toggled - const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false); - - // Trigger event - if (toggled) { - utils.dispatchEvent.call(this, this.media, 'controlsshown'); + // Trigger event on change + if (hiding !== isHidden) { + const eventName = hiding ? 'controlshidden' : 'controlsshown'; + utils.dispatchEvent.call(this, this.media, eventName); } - - // Always show controls when paused or if touch - if (this.paused || this.loading) { - return; - } - - // Delay for hiding on touch - if (this.touch) { - delay = 3000; - } - } - - // If toggle is false or if we're playing (regardless of toggle), - // then set the timer to hide the controls - if (!show || this.playing) { - this.timers.controls = setTimeout(() => { - // We need controls of course... - if (!utils.is.element(this.elements.controls)) { - return; - } - - // If the mouse is over the controls (and not entering fullscreen), bail - if ((this.elements.controls.pressed || this.elements.controls.hover) && !isEnterFullscreen) { - return; - } - - // Restore transition behaviour - if (!utils.hasClass(this.elements.container, this.config.classNames.hideControls)) { - utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, false); - } - - // Set hideControls class - const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, this.config.hideControls); - - // Trigger event and close menu - if (toggled) { - utils.dispatchEvent.call(this, this.media, 'controlshidden'); - - if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) { - controls.toggleMenu.call(this, false); - } - } - }, delay); + return !hiding; } + return false; } /** @@ -1266,4 +1180,6 @@ class Plyr { } } +Plyr.defaults = utils.cloneDeep(defaults); + export default Plyr; diff --git a/src/js/support.js b/src/js/support.js index 5528e898..38212d9f 100644 --- a/src/js/support.js +++ b/src/js/support.js @@ -133,6 +133,7 @@ const support = { }, }); window.addEventListener('test', null, options); + window.removeEventListener('test', null, options); } catch (e) { // Do nothing } diff --git a/src/js/ui.js b/src/js/ui.js index f844f93c..3a8f2d05 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -74,10 +74,10 @@ const ui = { this.quality = null; // Reset volume display - ui.updateVolume.call(this); + controls.updateVolume.call(this); // Reset time display - ui.timeUpdate.call(this); + controls.timeUpdate.call(this); // Update the UI ui.checkPlaying.call(this); @@ -199,7 +199,7 @@ const ui = { } // Toggle controls - this.toggleControls(!this.playing); + ui.toggleControls.call(this); }, // Check if media is loading @@ -214,171 +214,22 @@ const ui = { // Timer to prevent flicker when seeking this.timers.loading = setTimeout(() => { - // Toggle container class hook + // Update progress bar loading class state utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading); - // Show controls if loading, hide if done - this.toggleControls(this.loading); + // Update controls visibility + ui.toggleControls.call(this); }, this.loading ? 250 : 0); }, - // Check if media failed to load - checkFailed() { - // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/networkState - this.failed = this.media.networkState === 3; + // Toggle controls based on state and `force` argument + toggleControls(force) { + const { controls } = this.elements; - if (this.failed) { - utils.toggleClass(this.elements.container, this.config.classNames.loading, false); - utils.toggleClass(this.elements.container, this.config.classNames.error, true); + if (controls && this.config.hideControls) { + // Show controls if force, loading, paused, or button interaction, otherwise hide + this.toggleControls(Boolean(force || this.loading || this.paused || controls.pressed || controls.hover)); } - - // Clear timer - clearTimeout(this.timers.failed); - - // Timer to prevent flicker when seeking - this.timers.loading = setTimeout(() => { - // Toggle container class hook - utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading); - - // Show controls if loading, hide if done - this.toggleControls(this.loading); - }, this.loading ? 250 : 0); - }, - - // Update volume UI and storage - updateVolume() { - if (!this.supported.ui) { - return; - } - - // Update range - if (utils.is.element(this.elements.inputs.volume)) { - ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); - } - - // Update mute state - if (utils.is.element(this.elements.buttons.mute)) { - utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); - } - }, - - // Update seek value and lower fill - setRange(target, value = 0) { - if (!utils.is.element(target)) { - return; - } - - // eslint-disable-next-line - target.value = value; - - // Webkit range fill - controls.updateRangeFill.call(this, target); - }, - - // Set <progress> value - setProgress(target, input) { - const value = utils.is.number(input) ? input : 0; - const progress = utils.is.element(target) ? target : this.elements.display.buffer; - - // Update value and label - if (utils.is.element(progress)) { - progress.value = value; - - // Update text label inside - const label = progress.getElementsByTagName('span')[0]; - if (utils.is.element(label)) { - label.childNodes[0].nodeValue = value; - } - } - }, - - // Update <progress> elements - updateProgress(event) { - if (!this.supported.ui || !utils.is.event(event)) { - return; - } - - let value = 0; - - if (event) { - switch (event.type) { - // Video playing - case 'timeupdate': - case 'seeking': - value = utils.getPercentage(this.currentTime, this.duration); - - // Set seek range value only if it's a 'natural' time event - if (event.type === 'timeupdate') { - ui.setRange.call(this, this.elements.inputs.seek, value); - } - - break; - - // Check buffer status - case 'playing': - case 'progress': - ui.setProgress.call(this, this.elements.display.buffer, this.buffered * 100); - - break; - - default: - break; - } - } - }, - - // 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)) { - return; - } - - // Always display hours if duration is over an hour - const forceHours = utils.getHours(this.duration) > 0; - - // eslint-disable-next-line no-param-reassign - target.textContent = utils.formatTime(time, forceHours, inverted); - }, - - // 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; - - // Duration - ui.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) { - return; - } - - // Playing progress - ui.updateProgress.call(this, event); - }, - - // Show the duration on metadataloaded - durationUpdate() { - if (!this.supported.ui) { - return; - } - - // If there's a spot to display duration - const hasDuration = utils.is.element(this.elements.display.duration); - - // If there's only one time display, display duration there - if (!hasDuration && this.config.displayDuration && this.paused) { - ui.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration); - } - - // If there's a duration element, update content - if (hasDuration) { - ui.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); - } - - // Update the tooltip (if visible) - controls.updateSeekTooltip.call(this); }, }; diff --git a/src/js/utils.js b/src/js/utils.js index 0cd332dd..0c5a28d7 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -3,6 +3,7 @@ // ========================================================================== import loadjs from 'loadjs'; +import Storage from './storage'; import support from './support'; import { providers } from './types'; @@ -171,6 +172,8 @@ const utils = { // Only load once if ID set if (!hasId || !exists()) { + const useStorage = Storage.supported; + // Create container const container = document.createElement('div'); utils.toggleHidden(container, true); @@ -180,7 +183,7 @@ const utils = { } // Check in cache - if (support.storage) { + if (useStorage) { const cached = window.localStorage.getItem(prefix + id); isCached = cached !== null; @@ -199,7 +202,7 @@ const utils = { return; } - if (support.storage) { + if (useStorage) { window.localStorage.setItem( prefix + id, JSON.stringify({ @@ -262,7 +265,7 @@ const utils = { // Add text node if (utils.is.string(text)) { - element.textContent = text; + element.innerText = text; } // Return built element @@ -405,14 +408,16 @@ const utils = { } }, - // Toggle class on an element - toggleClass(element, className, toggle) { + // Mirror Element.classList.toggle, with IE compatibility for "force" argument + toggleClass(element, className, force) { if (utils.is.element(element)) { - const contains = element.classList.contains(className); - - element.classList[toggle ? 'add' : 'remove'](className); + let method = 'toggle'; + if (typeof force !== 'undefined') { + method = force ? 'add' : 'remove'; + } - return (toggle && !contains) || (!toggle && contains); + element.classList[method](className); + return element.classList.contains(className); } return null; @@ -595,7 +600,7 @@ const utils = { return input; } - return input.toString().replace(/{(\d+)}/g, (match, i) => utils.is.string(args[i]) ? args[i] : ''); + return input.toString().replace(/{(\d+)}/g, (match, i) => (utils.is.string(args[i]) ? args[i] : '')); }, // Get percentage @@ -718,6 +723,11 @@ const utils = { return array.filter((item, index) => array.indexOf(item) === index); }, + // Clone nested objects + cloneDeep(object) { + return JSON.parse(JSON.stringify(object)); + }, + // Get the closest value in an array closest(array, value) { if (!utils.is.array(array) || !array.length) { |