diff options
Diffstat (limited to 'src/js/plyr.js')
-rw-r--r-- | src/js/plyr.js | 2130 |
1 files changed, 1070 insertions, 1060 deletions
diff --git a/src/js/plyr.js b/src/js/plyr.js index 6eb337cf..7835d779 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -1,6 +1,6 @@ // ========================================================================== // Plyr -// plyr.js v3.5.10 +// plyr.js v3.6.1 // https://github.com/sampotts/plyr // License: The MIT License (MIT) // ========================================================================== @@ -28,6 +28,7 @@ import is from './utils/is'; import loadSprite from './utils/load-sprite'; import { clamp } from './utils/numbers'; import { cloneDeep, extend } from './utils/objects'; +import { silencePromise } from './utils/promise'; import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateRatio } from './utils/style'; import { parseUrl } from './utils/urls'; @@ -37,748 +38,752 @@ import { parseUrl } from './utils/urls'; // Plyr instance class Plyr { - constructor(target, options) { - this.timers = {}; - - // State - this.ready = false; - this.loading = false; - this.failed = false; - - // Touch device - this.touch = support.touch; - - // Set the media element - this.media = target; - - // String selector passed - 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) || is.nodeList(this.media) || is.array(this.media)) { - // eslint-disable-next-line - this.media = this.media[0]; - } - - // Set config - this.config = extend( - {}, - defaults, - Plyr.defaults, - options || {}, - (() => { - try { - return JSON.parse(this.media.getAttribute('data-plyr-config')); - } catch (e) { - return {}; - } - })(), - ); - - // Elements cache - this.elements = { - container: null, - captions: null, - buttons: {}, - display: {}, - progress: {}, - inputs: {}, - settings: { - popup: null, - menu: null, - panels: {}, - buttons: {}, - }, - }; - - // Captions - this.captions = { - active: null, - currentTrack: -1, - meta: new WeakMap(), - }; - - // Fullscreen - this.fullscreen = { - active: false, - }; - - // Options - this.options = { - speed: [], - quality: [], - }; - - // Debugging - // TODO: move to globals - this.debug = new Console(this.config.debug); - - // Log config options and support - this.debug.log('Config', this.config); - this.debug.log('Support', support); - - // We need an element to setup - if (is.nullOrUndefined(this.media) || !is.element(this.media)) { - this.debug.error('Setup failed: no suitable element passed'); - return; - } - - // Bail if the element is initialized - if (this.media.plyr) { - this.debug.warn('Target already setup'); - return; - } - - // Bail if not enabled - if (!this.config.enabled) { - this.debug.error('Setup failed: disabled by config'); - return; + constructor(target, options) { + this.timers = {}; + + // State + this.ready = false; + this.loading = false; + this.failed = false; + + // Touch device + this.touch = support.touch; + + // Set the media element + this.media = target; + + // String selector passed + 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) || is.nodeList(this.media) || is.array(this.media)) { + // eslint-disable-next-line + this.media = this.media[0]; + } + + // Set config + this.config = extend( + {}, + defaults, + Plyr.defaults, + options || {}, + (() => { + try { + return JSON.parse(this.media.getAttribute('data-plyr-config')); + } catch (e) { + return {}; } + })(), + ); + + // Elements cache + this.elements = { + container: null, + fullscreen: null, + captions: null, + buttons: {}, + display: {}, + progress: {}, + inputs: {}, + settings: { + popup: null, + menu: null, + panels: {}, + buttons: {}, + }, + }; + + // Captions + this.captions = { + active: null, + currentTrack: -1, + meta: new WeakMap(), + }; + + // Fullscreen + this.fullscreen = { + active: false, + }; + + // Options + this.options = { + speed: [], + quality: [], + }; + + // Debugging + // TODO: move to globals + this.debug = new Console(this.config.debug); + + // Log config options and support + this.debug.log('Config', this.config); + this.debug.log('Support', support); + + // We need an element to setup + if (is.nullOrUndefined(this.media) || !is.element(this.media)) { + this.debug.error('Setup failed: no suitable element passed'); + return; + } + + // Bail if the element is initialized + if (this.media.plyr) { + this.debug.warn('Target already setup'); + return; + } + + // Bail if not enabled + if (!this.config.enabled) { + this.debug.error('Setup failed: disabled by config'); + return; + } + + // Bail if disabled or no basic support + // You may want to disable certain UAs etc + if (!support.check().api) { + this.debug.error('Setup failed: no support'); + return; + } + + // Cache original element state for .destroy() + const clone = this.media.cloneNode(true); + clone.autoplay = false; + this.elements.original = clone; + + // Set media type based on tag or data attribute + // Supported: video, audio, vimeo, youtube + const type = this.media.tagName.toLowerCase(); + // Embed properties + let iframe = null; + let url = null; + + // Different setup based on type + switch (type) { + case 'div': + // Find the frame + iframe = this.media.querySelector('iframe'); + + // <iframe> type + if (is.element(iframe)) { + // Detect provider + url = parseUrl(iframe.getAttribute('src')); + this.provider = getProviderByUrl(url.toString()); + + // Rework elements + this.elements.container = this.media; + this.media = iframe; + + // Reset classname + this.elements.container.className = ''; + + // Get attributes from URL and set config + if (url.search.length) { + const truthy = ['1', 'true']; + + if (truthy.includes(url.searchParams.get('autoplay'))) { + this.config.autoplay = true; + } + if (truthy.includes(url.searchParams.get('loop'))) { + this.config.loop.active = true; + } - // Bail if disabled or no basic support - // You may want to disable certain UAs etc - if (!support.check().api) { - this.debug.error('Setup failed: no support'); - return; - } - - // Cache original element state for .destroy() - const clone = this.media.cloneNode(true); - clone.autoplay = false; - this.elements.original = clone; - - // Set media type based on tag or data attribute - // Supported: video, audio, vimeo, youtube - const type = this.media.tagName.toLowerCase(); - // Embed properties - let iframe = null; - let url = null; - - // Different setup based on type - switch (type) { - case 'div': - // Find the frame - iframe = this.media.querySelector('iframe'); - - // <iframe> type - if (is.element(iframe)) { - // Detect provider - url = parseUrl(iframe.getAttribute('src')); - this.provider = getProviderByUrl(url.toString()); - - // Rework elements - this.elements.container = this.media; - this.media = iframe; - - // Reset classname - this.elements.container.className = ''; - - // Get attributes from URL and set config - if (url.search.length) { - const truthy = ['1', 'true']; - - if (truthy.includes(url.searchParams.get('autoplay'))) { - this.config.autoplay = true; - } - 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(url.searchParams.get('playsinline')); - this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language? - } else { - this.config.playsinline = true; - } - } - } else { - // <div> with attributes - this.provider = this.media.getAttribute(this.config.attributes.embed.provider); - - // Remove attribute - this.media.removeAttribute(this.config.attributes.embed.provider); - } - - // Unsupported or missing provider - if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) { - this.debug.error('Setup failed: Invalid provider'); - return; - } - - // Audio will come later for external providers - this.type = types.video; - - break; - - case 'video': - case 'audio': - this.type = type; - this.provider = providers.html5; - - // Get config from attributes - if (this.media.hasAttribute('crossorigin')) { - this.config.crossorigin = true; - } - if (this.media.hasAttribute('autoplay')) { - this.config.autoplay = true; - } - if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) { - this.config.playsinline = true; - } - if (this.media.hasAttribute('muted')) { - this.config.muted = true; - } - if (this.media.hasAttribute('loop')) { - this.config.loop.active = true; - } - - break; + // TODO: replace fullscreen.iosNative with this playsinline config option + // YouTube requires the playsinline in the URL + if (this.isYouTube) { + this.config.playsinline = truthy.includes(url.searchParams.get('playsinline')); + this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language? + } else { + this.config.playsinline = true; + } + } + } else { + // <div> with attributes + this.provider = this.media.getAttribute(this.config.attributes.embed.provider); - default: - this.debug.error('Setup failed: unsupported type'); - return; + // Remove attribute + this.media.removeAttribute(this.config.attributes.embed.provider); } - // Check for support again but with type - this.supported = support.check(this.type, this.provider, this.config.playsinline); - - // If no support for even API, bail - if (!this.supported.api) { - this.debug.error('Setup failed: no support'); - return; + // Unsupported or missing provider + if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) { + this.debug.error('Setup failed: Invalid provider'); + return; } - this.eventListeners = []; + // Audio will come later for external providers + this.type = types.video; - // Create listeners - this.listeners = new Listeners(this); + break; - // Setup local storage for user settings - this.storage = new Storage(this); + case 'video': + case 'audio': + this.type = type; + this.provider = providers.html5; - // Store reference - this.media.plyr = this; - - // Wrap media - if (!is.element(this.elements.container)) { - this.elements.container = createElement('div', { tabindex: 0 }); - wrap(this.media, this.elements.container); + // Get config from attributes + if (this.media.hasAttribute('crossorigin')) { + this.config.crossorigin = true; } - - // Add style hook - ui.addStyleHook.call(this); - - // Setup media - media.setup.call(this); - - // Listen for events if debugging - if (this.config.debug) { - on.call(this, this.elements.container, this.config.events.join(' '), event => { - this.debug.log(`event: ${event.type}`); - }); + if (this.media.hasAttribute('autoplay')) { + this.config.autoplay = true; } - - // Setup interface - // If embed but not fully supported, build interface now to avoid flash of controls - if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) { - ui.build.call(this); + if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) { + this.config.playsinline = true; } - - // Container listeners - this.listeners.container(); - - // Global listeners - this.listeners.global(); - - // Setup fullscreen - this.fullscreen = new Fullscreen(this); - - // Setup ads if provided - if (this.config.ads.enabled) { - this.ads = new Ads(this); - } - - // Autoplay if required - if (this.isHTML5 && this.config.autoplay) { - setTimeout(() => this.play(), 10); + if (this.media.hasAttribute('muted')) { + this.config.muted = true; } - - // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek - this.lastSeekTime = 0; - - // Setup preview thumbnails if enabled - if (this.config.previewThumbnails.enabled) { - this.previewThumbnails = new PreviewThumbnails(this); + if (this.media.hasAttribute('loop')) { + this.config.loop.active = true; } - } - // --------------------------------------- - // API - // --------------------------------------- + break; - /** - * Types and provider helpers - */ - get isHTML5() { - return this.provider === providers.html5; + default: + this.debug.error('Setup failed: unsupported type'); + return; } - get isEmbed() { - return this.isYouTube || this.isVimeo; - } + // Check for support again but with type + this.supported = support.check(this.type, this.provider, this.config.playsinline); - get isYouTube() { - return this.provider === providers.youtube; + // If no support for even API, bail + if (!this.supported.api) { + this.debug.error('Setup failed: no support'); + return; } - get isVimeo() { - return this.provider === providers.vimeo; - } + this.eventListeners = []; - get isVideo() { - return this.type === types.video; - } + // Create listeners + this.listeners = new Listeners(this); - get isAudio() { - return this.type === types.audio; - } - - /** - * Play the media, or play the advertisement (if they are not blocked) - */ - play() { - if (!is.function(this.media.play)) { - return null; - } + // Setup local storage for user settings + this.storage = new Storage(this); - // Intecept play with ads - if (this.ads && this.ads.enabled) { - this.ads.managerPromise.then(() => this.ads.play()).catch(() => this.media.play()); - } + // Store reference + this.media.plyr = this; - // Return the promise (for HTML5) - return this.media.play(); + // Wrap media + if (!is.element(this.elements.container)) { + this.elements.container = createElement('div', { tabindex: 0 }); + wrap(this.media, this.elements.container); } - /** - * Pause the media - */ - pause() { - if (!this.playing || !is.function(this.media.pause)) { - return null; - } + // Migrate custom properties from media to container (so they work 😉) + ui.migrateStyles.call(this); - return this.media.pause(); - } + // Add style hook + ui.addStyleHook.call(this); - /** - * Get playing state - */ - get playing() { - return Boolean(this.ready && !this.paused && !this.ended); - } + // Setup media + media.setup.call(this); - /** - * Get paused state - */ - get paused() { - return Boolean(this.media.paused); + // Listen for events if debugging + if (this.config.debug) { + on.call(this, this.elements.container, this.config.events.join(' '), event => { + this.debug.log(`event: ${event.type}`); + }); } - /** - * Get stopped state - */ - get stopped() { - return Boolean(this.paused && this.currentTime === 0); - } + // Setup fullscreen + this.fullscreen = new Fullscreen(this); - /** - * Get ended state - */ - get ended() { - return Boolean(this.media.ended); + // Setup interface + // If embed but not fully supported, build interface now to avoid flash of controls + if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) { + ui.build.call(this); } - /** - * Toggle playback based on current status - * @param {Boolean} input - */ - togglePlay(input) { - // Toggle based on current state if nothing passed - const toggle = is.boolean(input) ? input : !this.playing; - - if (toggle) { - return this.play(); - } + // Container listeners + this.listeners.container(); - return this.pause(); - } + // Global listeners + this.listeners.global(); - /** - * Stop playback - */ - stop() { - if (this.isHTML5) { - this.pause(); - this.restart(); - } else if (is.function(this.media.stop)) { - this.media.stop(); - } + // Setup ads if provided + if (this.config.ads.enabled) { + this.ads = new Ads(this); } - /** - * Restart playback - */ - restart() { - this.currentTime = 0; + // Autoplay if required + if (this.isHTML5 && this.config.autoplay) { + setTimeout(() => silencePromise(this.play()), 10); } - /** - * Rewind - * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime - */ - rewind(seekTime) { - this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime; - } + // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek + this.lastSeekTime = 0; - /** - * Fast forward - * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime - */ - forward(seekTime) { - this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime; + // Setup preview thumbnails if enabled + if (this.config.previewThumbnails.enabled) { + this.previewThumbnails = new PreviewThumbnails(this); } + } - /** - * Seek to a time - * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start) - */ - set currentTime(input) { - // Bail if media duration isn't available yet - if (!this.duration) { - return; - } + // --------------------------------------- + // API + // --------------------------------------- - // Validate input - const inputIsValid = is.number(input) && input > 0; + /** + * Types and provider helpers + */ + get isHTML5() { + return this.provider === providers.html5; + } - // Set - this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0; + get isEmbed() { + return this.isYouTube || this.isVimeo; + } - // Logging - this.debug.log(`Seeking to ${this.currentTime} seconds`); - } - - /** - * Get current time - */ - get currentTime() { - return Number(this.media.currentTime); - } + get isYouTube() { + return this.provider === providers.youtube; + } - /** - * Get buffered - */ - get buffered() { - const { buffered } = this.media; + get isVimeo() { + return this.provider === providers.vimeo; + } - // YouTube / Vimeo return a float between 0-1 - if (is.number(buffered)) { - return buffered; - } + get isVideo() { + return this.type === types.video; + } - // HTML5 - // TODO: Handle buffered chunks of the media - // (i.e. seek to another section buffers only that section) - if (buffered && buffered.length && this.duration > 0) { - return buffered.end(0) / this.duration; - } + get isAudio() { + return this.type === types.audio; + } - return 0; + /** + * Play the media, or play the advertisement (if they are not blocked) + */ + play() { + if (!is.function(this.media.play)) { + return null; } - /** - * Get seeking status - */ - get seeking() { - return Boolean(this.media.seeking); - } + // Intecept play with ads + if (this.ads && this.ads.enabled) { + this.ads.managerPromise.then(() => this.ads.play()).catch(() => silencePromise(this.media.play())); + } + + // Return the promise (for HTML5) + return this.media.play(); + } + + /** + * Pause the media + */ + pause() { + if (!this.playing || !is.function(this.media.pause)) { + return null; + } + + return this.media.pause(); + } + + /** + * Get playing state + */ + get playing() { + return Boolean(this.ready && !this.paused && !this.ended); + } + + /** + * Get paused state + */ + get paused() { + return Boolean(this.media.paused); + } + + /** + * Get stopped state + */ + get stopped() { + return Boolean(this.paused && this.currentTime === 0); + } + + /** + * Get ended state + */ + get ended() { + return Boolean(this.media.ended); + } + + /** + * Toggle playback based on current status + * @param {Boolean} input + */ + togglePlay(input) { + // Toggle based on current state if nothing passed + const toggle = is.boolean(input) ? input : !this.playing; + + if (toggle) { + return this.play(); + } + + return this.pause(); + } + + /** + * Stop playback + */ + stop() { + if (this.isHTML5) { + this.pause(); + this.restart(); + } else if (is.function(this.media.stop)) { + this.media.stop(); + } + } + + /** + * Restart playback + */ + restart() { + this.currentTime = 0; + } + + /** + * Rewind + * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime + */ + rewind(seekTime) { + this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime; + } + + /** + * Fast forward + * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime + */ + forward(seekTime) { + this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime; + } + + /** + * Seek to a time + * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start) + */ + set currentTime(input) { + // Bail if media duration isn't available yet + if (!this.duration) { + return; + } + + // Validate input + const inputIsValid = is.number(input) && input > 0; + + // Set + this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0; + + // Logging + this.debug.log(`Seeking to ${this.currentTime} seconds`); + } + + /** + * Get current time + */ + get currentTime() { + return Number(this.media.currentTime); + } + + /** + * Get buffered + */ + get buffered() { + const { buffered } = this.media; + + // YouTube / Vimeo return a float between 0-1 + if (is.number(buffered)) { + return buffered; + } + + // HTML5 + // TODO: Handle buffered chunks of the media + // (i.e. seek to another section buffers only that section) + if (buffered && buffered.length && this.duration > 0) { + return buffered.end(0) / this.duration; + } + + return 0; + } + + /** + * Get seeking status + */ + get seeking() { + return Boolean(this.media.seeking); + } + + /** + * Get the duration of the current media + */ + get duration() { + // Faux duration set via config + const fauxDuration = parseFloat(this.config.duration); + // Media duration can be NaN or Infinity before the media has loaded + const realDuration = (this.media || {}).duration; + const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration; + + // If config duration is funky, use regular duration + return fauxDuration || duration; + } + + /** + * Set the player volume + * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage + */ + set volume(value) { + let volume = value; + const max = 1; + const min = 0; + + if (is.string(volume)) { + volume = Number(volume); + } + + // Load volume from storage if no value specified + if (!is.number(volume)) { + volume = this.storage.get('volume'); + } + + // Use config if all else fails + if (!is.number(volume)) { + ({ volume } = this.config); + } + + // Maximum is volumeMax + if (volume > max) { + volume = max; + } + // Minimum is volumeMin + if (volume < min) { + volume = min; + } + + // Update config + this.config.volume = volume; + + // Set the player volume + this.media.volume = volume; - /** - * Get the duration of the current media - */ - get duration() { - // Faux duration set via config - const fauxDuration = parseFloat(this.config.duration); - // Media duration can be NaN or Infinity before the media has loaded - const realDuration = (this.media || {}).duration; - const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration; - - // If config duration is funky, use regular duration - return fauxDuration || duration; + // If muted, and we're increasing volume manually, reset muted state + if (!is.empty(value) && this.muted && volume > 0) { + this.muted = false; } + } - /** - * Set the player volume - * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage - */ - set volume(value) { - let volume = value; - const max = 1; - const min = 0; - - if (is.string(volume)) { - volume = Number(volume); - } + /** + * Get the current player volume + */ + get volume() { + return Number(this.media.volume); + } - // Load volume from storage if no value specified - if (!is.number(volume)) { - volume = this.storage.get('volume'); - } + /** + * Increase volume + * @param {Boolean} step - How much to decrease by (between 0 and 1) + */ + increaseVolume(step) { + const volume = this.media.muted ? 0 : this.volume; + this.volume = volume + (is.number(step) ? step : 0); + } - // Use config if all else fails - if (!is.number(volume)) { - ({ volume } = this.config); - } + /** + * Decrease volume + * @param {Boolean} step - How much to decrease by (between 0 and 1) + */ + decreaseVolume(step) { + this.increaseVolume(-step); + } - // Maximum is volumeMax - if (volume > max) { - volume = max; - } - // Minimum is volumeMin - if (volume < min) { - volume = min; - } + /** + * Set muted state + * @param {Boolean} mute + */ + set muted(mute) { + let toggle = mute; - // Update config - this.config.volume = volume; + // Load muted state from storage + if (!is.boolean(toggle)) { + toggle = this.storage.get('muted'); + } + + // Use config if all else fails + if (!is.boolean(toggle)) { + toggle = this.config.muted; + } - // Set the player volume - this.media.volume = volume; + // Update config + this.config.muted = toggle; - // If muted, and we're increasing volume manually, reset muted state - if (!is.empty(value) && this.muted && volume > 0) { - this.muted = false; - } - } + // Set mute on the player + this.media.muted = toggle; + } - /** - * Get the current player volume - */ - get volume() { - return Number(this.media.volume); - } + /** + * Get current muted state + */ + get muted() { + return Boolean(this.media.muted); + } - /** - * Increase volume - * @param {Boolean} step - How much to decrease by (between 0 and 1) - */ - increaseVolume(step) { - const volume = this.media.muted ? 0 : this.volume; - this.volume = volume + (is.number(step) ? step : 0); + /** + * Check if the media has audio + */ + get hasAudio() { + // Assume yes for all non HTML5 (as we can't tell...) + if (!this.isHTML5) { + return true; } - /** - * Decrease volume - * @param {Boolean} step - How much to decrease by (between 0 and 1) - */ - decreaseVolume(step) { - this.increaseVolume(-step); + if (this.isAudio) { + return true; } - /** - * Set muted state - * @param {Boolean} mute - */ - set muted(mute) { - let toggle = mute; - - // Load muted state from storage - if (!is.boolean(toggle)) { - toggle = this.storage.get('muted'); - } - - // Use config if all else fails - if (!is.boolean(toggle)) { - toggle = this.config.muted; - } + // Get audio tracks + return ( + Boolean(this.media.mozHasAudio) || + Boolean(this.media.webkitAudioDecodedByteCount) || + Boolean(this.media.audioTracks && this.media.audioTracks.length) + ); + } - // Update config - this.config.muted = toggle; + /** + * Set playback speed + * @param {Number} speed - the speed of playback (0.5-2.0) + */ + set speed(input) { + let speed = null; - // Set mute on the player - this.media.muted = toggle; + if (is.number(input)) { + speed = input; } - /** - * Get current muted state - */ - get muted() { - return Boolean(this.media.muted); + if (!is.number(speed)) { + speed = this.storage.get('speed'); } - /** - * Check if the media has audio - */ - get hasAudio() { - // Assume yes for all non HTML5 (as we can't tell...) - if (!this.isHTML5) { - return true; - } - - if (this.isAudio) { - return true; - } - - // Get audio tracks - return ( - Boolean(this.media.mozHasAudio) || - Boolean(this.media.webkitAudioDecodedByteCount) || - Boolean(this.media.audioTracks && this.media.audioTracks.length) - ); + if (!is.number(speed)) { + speed = this.config.speed.selected; } - /** - * Set playback speed - * @param {Number} speed - the speed of playback (0.5-2.0) - */ - set speed(input) { - let speed = null; + // Clamp to min/max + const { minimumSpeed: min, maximumSpeed: max } = this; + speed = clamp(speed, min, max); - if (is.number(input)) { - speed = input; - } + // Update config + this.config.speed.selected = speed; - if (!is.number(speed)) { - speed = this.storage.get('speed'); - } - - if (!is.number(speed)) { - speed = this.config.speed.selected; - } - - // Clamp to min/max - const { minimumSpeed: min, maximumSpeed: max } = this; - speed = clamp(speed, min, max); + // Set media speed + setTimeout(() => { + this.media.playbackRate = speed; + }, 0); + } + + /** + * Get current playback speed + */ + get speed() { + return Number(this.media.playbackRate); + } - // Update config - this.config.speed.selected = speed; - - // Set media speed - setTimeout(() => { - this.media.playbackRate = speed; - }, 0); + /** + * Get the minimum allowed speed + */ + get minimumSpeed() { + if (this.isYouTube) { + // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + return Math.min(...this.options.speed); } - /** - * Get current playback speed - */ - get speed() { - return Number(this.media.playbackRate); + if (this.isVimeo) { + // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror + return 0.5; } - /** - * Get the minimum allowed speed - */ - get minimumSpeed() { - if (this.isYouTube) { - // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate - return Math.min(...this.options.speed); - } + // https://stackoverflow.com/a/32320020/1191319 + return 0.0625; + } - if (this.isVimeo) { - // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror - return 0.5; - } + /** + * Get the maximum allowed speed + */ + get maximumSpeed() { + if (this.isYouTube) { + // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + return Math.max(...this.options.speed); + } - // https://stackoverflow.com/a/32320020/1191319 - return 0.0625; + if (this.isVimeo) { + // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror + return 2; } - /** - * Get the maximum allowed speed - */ - get maximumSpeed() { - if (this.isYouTube) { - // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate - return Math.max(...this.options.speed); - } + // https://stackoverflow.com/a/32320020/1191319 + return 16; + } - if (this.isVimeo) { - // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror - return 2; - } + /** + * Set playback quality + * Currently HTML5 & YouTube only + * @param {Number} input - Quality level + */ + set quality(input) { + const config = this.config.quality; + const options = this.options.quality; - // https://stackoverflow.com/a/32320020/1191319 - return 16; + if (!options.length) { + return; } - /** - * Set playback quality - * Currently HTML5 & YouTube only - * @param {Number} input - Quality level - */ - set quality(input) { - const config = this.config.quality; - const options = this.options.quality; - - if (!options.length) { - return; - } - - let quality = [ - !is.empty(input) && Number(input), - this.storage.get('quality'), - config.selected, - config.default, - ].find(is.number); + let quality = [ + !is.empty(input) && Number(input), + this.storage.get('quality'), + config.selected, + config.default, + ].find(is.number); - let updateStorage = true; + let updateStorage = true; - if (!options.includes(quality)) { - const value = closest(options, quality); - this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`); - quality = value; + if (!options.includes(quality)) { + const value = closest(options, quality); + this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`); + quality = value; - // Don't update storage if quality is not supported - updateStorage = false; - } + // Don't update storage if quality is not supported + updateStorage = false; + } - // Update config - config.selected = quality; + // Update config + config.selected = quality; - // Set quality - this.media.quality = quality; + // Set quality + this.media.quality = quality; - // Save to storage - if (updateStorage) { - this.storage.set({ quality }); - } + // Save to storage + if (updateStorage) { + this.storage.set({ quality }); } + } - /** - * Get current quality level - */ - get quality() { - return this.media.quality; - } + /** + * Get current quality level + */ + get quality() { + return this.media.quality; + } - /** - * Toggle loop - * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config - * @param {Boolean} input - Whether to loop or not - */ - set loop(input) { - const toggle = is.boolean(input) ? input : this.config.loop.active; - this.config.loop.active = toggle; - this.media.loop = toggle; + /** + * Toggle loop + * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config + * @param {Boolean} input - Whether to loop or not + */ + set loop(input) { + const toggle = is.boolean(input) ? input : this.config.loop.active; + this.config.loop.active = toggle; + this.media.loop = toggle; - // Set default to be a true toggle - /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle'; + // Set default to be a true toggle + /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle'; switch (type) { case 'start': @@ -819,436 +824,441 @@ class Plyr { this.config.loop.end = null; break; } */ - } - - /** - * Get current loop state - */ - get loop() { - return Boolean(this.media.loop); - } - - /** - * Set new media source - * @param {Object} input - The new source object (see docs) - */ - set source(input) { - source.change.call(this, input); - } - - /** - * Get current source - */ - get source() { - return this.media.currentSrc; - } - - /** - * Get a download URL (either source or custom) - */ - get download() { - const { download } = this.config.urls; - - return is.url(download) ? download : this.source; - } + } - /** - * Set the download URL - */ - set download(input) { - if (!is.url(input)) { - return; + /** + * Get current loop state + */ + get loop() { + return Boolean(this.media.loop); + } + + /** + * Set new media source + * @param {Object} input - The new source object (see docs) + */ + set source(input) { + source.change.call(this, input); + } + + /** + * Get current source + */ + get source() { + return this.media.currentSrc; + } + + /** + * Get a download URL (either source or custom) + */ + get download() { + const { download } = this.config.urls; + + return is.url(download) ? download : this.source; + } + + /** + * Set the download URL + */ + set download(input) { + if (!is.url(input)) { + return; + } + + this.config.urls.download = input; + + controls.setDownloadUrl.call(this); + } + + /** + * Set the poster image for a video + * @param {String} input - the URL for the new poster image + */ + set poster(input) { + if (!this.isVideo) { + this.debug.warn('Poster can only be set for video'); + return; + } + + ui.setPoster.call(this, input, false).catch(() => {}); + } + + /** + * Get the current poster image + */ + get poster() { + if (!this.isVideo) { + return null; + } + + return this.media.getAttribute('poster') || this.media.getAttribute('data-poster'); + } + + /** + * Get the current aspect ratio in use + */ + get ratio() { + if (!this.isVideo) { + return null; + } + + const ratio = reduceAspectRatio(getAspectRatio.call(this)); + + return is.array(ratio) ? ratio.join(':') : ratio; + } + + /** + * Set video aspect ratio + */ + set ratio(input) { + if (!this.isVideo) { + this.debug.warn('Aspect ratio can only be set for video'); + return; + } + + if (!is.string(input) || !validateRatio(input)) { + this.debug.error(`Invalid aspect ratio specified (${input})`); + return; + } + + this.config.ratio = input; + + setAspectRatio.call(this); + } + + /** + * Set the autoplay state + * @param {Boolean} input - Whether to autoplay or not + */ + set autoplay(input) { + const toggle = is.boolean(input) ? input : this.config.autoplay; + this.config.autoplay = toggle; + } + + /** + * Get the current autoplay state + */ + get autoplay() { + return Boolean(this.config.autoplay); + } + + /** + * Toggle captions + * @param {Boolean} input - Whether to enable captions + */ + toggleCaptions(input) { + captions.toggle.call(this, input, false); + } + + /** + * Set the caption track by index + * @param {Number} - Caption index + */ + set currentTrack(input) { + captions.set.call(this, input, false); + } + + /** + * Get the current caption track index (-1 if disabled) + */ + get currentTrack() { + const { toggled, currentTrack } = this.captions; + return toggled ? currentTrack : -1; + } + + /** + * Set the wanted language for captions + * Since tracks can be added later it won't update the actual caption track until there is a matching track + * @param {String} - Two character ISO language code (e.g. EN, FR, PT, etc) + */ + set language(input) { + captions.setLanguage.call(this, input, false); + } + + /** + * Get the current track's language + */ + get language() { + return (captions.getCurrentTrack.call(this) || {}).language; + } + + /** + * Toggle picture-in-picture playback on WebKit/MacOS + * TODO: update player with state, support, enabled + * TODO: detect outside changes + */ + set pip(input) { + // Bail if no support + if (!support.pip) { + return; + } + + // Toggle based on current state if not passed + const toggle = is.boolean(input) ? input : !this.pip; + + // Toggle based on current state + // Safari + if (is.function(this.media.webkitSetPresentationMode)) { + this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive); + } + + // Chrome + if (is.function(this.media.requestPictureInPicture)) { + if (!this.pip && toggle) { + this.media.requestPictureInPicture(); + } else if (this.pip && !toggle) { + document.exitPictureInPicture(); + } + } + } + + /** + * Get the current picture-in-picture state + */ + get pip() { + if (!support.pip) { + return null; + } + + // Safari + if (!is.empty(this.media.webkitPresentationMode)) { + return this.media.webkitPresentationMode === pip.active; + } + + // Chrome + return this.media === document.pictureInPictureElement; + } + + /** + * Trigger the airplay dialog + * TODO: update player with state, support, enabled + */ + airplay() { + // Show dialog if supported + if (support.airplay) { + this.media.webkitShowPlaybackTargetPicker(); + } + } + + /** + * Toggle the player controls + * @param {Boolean} [toggle] - Whether to show the controls + */ + toggleControls(toggle) { + // Don't toggle if missing UI support or if it's audio + if (this.supported.ui && !this.isAudio) { + // Get state before change + 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 = toggleClass(this.elements.container, this.config.classNames.hideControls, force); + + // Close menu + if ( + hiding && + is.array(this.config.controls) && + 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'; + triggerEvent.call(this, this.media, eventName); + } + + return !hiding; + } + + return false; + } + + /** + * Add event listeners + * @param {String} event - Event type + * @param {Function} callback - Callback for when event occurs + */ + on(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) { + off(this.elements.container, event, callback); + } + + /** + * Destroy an instance + * Event listeners are removed when elements are removed + * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory + * @param {Function} callback - Callback for when destroy is complete + * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc) + */ + destroy(callback, soft = false) { + if (!this.ready) { + return; + } + + const done = () => { + // Reset overflow (incase destroyed while in fullscreen) + document.body.style.overflow = ''; + + // GC for embed + this.embed = null; + + // If it's a soft destroy, make minimal changes + if (soft) { + if (Object.keys(this.elements).length) { + // Remove elements + 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; + this.elements.captions = null; + this.elements.controls = null; + this.elements.wrapper = null; } - this.config.urls.download = input; - - controls.setDownloadUrl.call(this); - } - - /** - * Set the poster image for a video - * @param {String} input - the URL for the new poster image - */ - set poster(input) { - if (!this.isVideo) { - this.debug.warn('Poster can only be set for video'); - return; - } - - ui.setPoster.call(this, input, false).catch(() => {}); - } - - /** - * Get the current poster image - */ - get poster() { - if (!this.isVideo) { - return null; + // Callback + if (is.function(callback)) { + callback(); } + } else { + // Unbind listeners + unbindListeners.call(this); - return this.media.getAttribute('poster'); - } - - /** - * Get the current aspect ratio in use - */ - get ratio() { - if (!this.isVideo) { - return null; - } - - const ratio = reduceAspectRatio(getAspectRatio.call(this)); + // Cancel current network requests + html5.cancelRequests.call(this); - return is.array(ratio) ? ratio.join(':') : ratio; - } - - /** - * Set video aspect ratio - */ - set ratio(input) { - if (!this.isVideo) { - this.debug.warn('Aspect ratio can only be set for video'); - return; - } + // Replace the container with the original element provided + replaceElement(this.elements.original, this.elements.container); - if (!is.string(input) || !validateRatio(input)) { - this.debug.error(`Invalid aspect ratio specified (${input})`); - return; - } - - this.config.ratio = input; - - setAspectRatio.call(this); - } - - /** - * Set the autoplay state - * @param {Boolean} input - Whether to autoplay or not - */ - set autoplay(input) { - const toggle = is.boolean(input) ? input : this.config.autoplay; - this.config.autoplay = toggle; - } - - /** - * Get the current autoplay state - */ - get autoplay() { - return Boolean(this.config.autoplay); - } - - /** - * Toggle captions - * @param {Boolean} input - Whether to enable captions - */ - toggleCaptions(input) { - captions.toggle.call(this, input, false); - } - - /** - * Set the caption track by index - * @param {Number} - Caption index - */ - set currentTrack(input) { - captions.set.call(this, input, false); - } - - /** - * Get the current caption track index (-1 if disabled) - */ - get currentTrack() { - const { toggled, currentTrack } = this.captions; - return toggled ? currentTrack : -1; - } - - /** - * Set the wanted language for captions - * Since tracks can be added later it won't update the actual caption track until there is a matching track - * @param {String} - Two character ISO language code (e.g. EN, FR, PT, etc) - */ - set language(input) { - captions.setLanguage.call(this, input, false); - } - - /** - * Get the current track's language - */ - get language() { - return (captions.getCurrentTrack.call(this) || {}).language; - } - - /** - * Toggle picture-in-picture playback on WebKit/MacOS - * TODO: update player with state, support, enabled - * TODO: detect outside changes - */ - set pip(input) { - // Bail if no support - if (!support.pip) { - return; - } - - // Toggle based on current state if not passed - const toggle = is.boolean(input) ? input : !this.pip; - - // Toggle based on current state - // Safari - if (is.function(this.media.webkitSetPresentationMode)) { - this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive); - } - - // Chrome - if (is.function(this.media.requestPictureInPicture)) { - if (!this.pip && toggle) { - this.media.requestPictureInPicture(); - } else if (this.pip && !toggle) { - document.exitPictureInPicture(); - } - } - } + // Event + triggerEvent.call(this, this.elements.original, 'destroyed', true); - /** - * Get the current picture-in-picture state - */ - get pip() { - if (!support.pip) { - return null; + // Callback + if (is.function(callback)) { + callback.call(this.elements.original); } - // Safari - if (!is.empty(this.media.webkitPresentationMode)) { - return this.media.webkitPresentationMode === pip.active; - } - - // Chrome - return this.media === document.pictureInPictureElement; - } - - /** - * Trigger the airplay dialog - * TODO: update player with state, support, enabled - */ - airplay() { - // Show dialog if supported - if (support.airplay) { - this.media.webkitShowPlaybackTargetPicker(); - } - } - - /** - * Toggle the player controls - * @param {Boolean} [toggle] - Whether to show the controls - */ - toggleControls(toggle) { - // Don't toggle if missing UI support or if it's audio - if (this.supported.ui && !this.isAudio) { - // Get state before change - 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 = toggleClass(this.elements.container, this.config.classNames.hideControls, force); - - // Close menu - 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'; - triggerEvent.call(this, this.media, eventName); - } - - return !hiding; - } - - return false; - } - - /** - * Add event listeners - * @param {String} event - Event type - * @param {Function} callback - Callback for when event occurs - */ - on(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) { - off(this.elements.container, event, callback); - } - - /** - * Destroy an instance - * Event listeners are removed when elements are removed - * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory - * @param {Function} callback - Callback for when destroy is complete - * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc) - */ - destroy(callback, soft = false) { - if (!this.ready) { - return; - } - - const done = () => { - // Reset overflow (incase destroyed while in fullscreen) - document.body.style.overflow = ''; - - // GC for embed - this.embed = null; - - // If it's a soft destroy, make minimal changes - if (soft) { - if (Object.keys(this.elements).length) { - // Remove elements - 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; - this.elements.captions = null; - this.elements.controls = null; - this.elements.wrapper = null; - } - - // Callback - if (is.function(callback)) { - callback(); - } - } else { - // Unbind listeners - unbindListeners.call(this); - - // Cancel current network requests - html5.cancelRequests.call(this); - - // Replace the container with the original element provided - replaceElement(this.elements.original, this.elements.container); - - // Event - triggerEvent.call(this, this.elements.original, 'destroyed', true); - - // Callback - if (is.function(callback)) { - callback.call(this.elements.original); - } - - // Reset state - this.ready = false; - - // Clear for garbage collection - setTimeout(() => { - this.elements = null; - this.media = null; - }, 200); - } - }; - - // Stop playback - this.stop(); - - // Clear timeouts - clearTimeout(this.timers.loading); - clearTimeout(this.timers.controls); - clearTimeout(this.timers.resized); - - // Provider specific stuff - if (this.isHTML5) { - // Restore native video controls - ui.toggleNativeControls.call(this, true); - - // Clean up - done(); - } else if (this.isYouTube) { - // Clear timers - clearInterval(this.timers.buffering); - clearInterval(this.timers.playing); - - // Destroy YouTube API - if (this.embed !== null && is.function(this.embed.destroy)) { - this.embed.destroy(); - } - - // Clean up - done(); - } else if (this.isVimeo) { - // Destroy Vimeo API - // then clean up (wait, to prevent postmessage errors) - if (this.embed !== null) { - this.embed.unload().then(done); - } - - // Vimeo does not always return - setTimeout(done, 200); - } - } - - /** - * Check for support for a mime type (HTML5 only) - * @param {String} type - Mime type - */ - supports(type) { - return support.mime.call(this, type); - } - - /** - * Check for support - * @param {String} type - Player type (audio/video) - * @param {String} provider - Provider (html5/youtube/vimeo) - * @param {Boolean} inline - Where player has `playsinline` sttribute - */ - static supported(type, provider, inline) { - return support.check(type, provider, inline); - } - - /** - * Load an SVG sprite into the page - * @param {String} url - URL for the SVG sprite - * @param {String} [id] - Unique ID - */ - static loadSprite(url, id) { - return loadSprite(url, id); - } - - /** - * Setup multiple instances - * @param {*} selector - * @param {Object} options - */ - static setup(selector, options = {}) { - let targets = null; - - if (is.string(selector)) { - targets = Array.from(document.querySelectorAll(selector)); - } else if (is.nodeList(selector)) { - targets = Array.from(selector); - } else if (is.array(selector)) { - targets = selector.filter(is.element); - } - - if (is.empty(targets)) { - return null; - } + // Reset state + this.ready = false; - return targets.map(t => new Plyr(t, options)); - } + // Clear for garbage collection + setTimeout(() => { + this.elements = null; + this.media = null; + }, 200); + } + }; + + // Stop playback + this.stop(); + + // Clear timeouts + clearTimeout(this.timers.loading); + clearTimeout(this.timers.controls); + clearTimeout(this.timers.resized); + + // Provider specific stuff + if (this.isHTML5) { + // Restore native video controls + ui.toggleNativeControls.call(this, true); + + // Clean up + done(); + } else if (this.isYouTube) { + // Clear timers + clearInterval(this.timers.buffering); + clearInterval(this.timers.playing); + + // Destroy YouTube API + if (this.embed !== null && is.function(this.embed.destroy)) { + this.embed.destroy(); + } + + // Clean up + done(); + } else if (this.isVimeo) { + // Destroy Vimeo API + // then clean up (wait, to prevent postmessage errors) + if (this.embed !== null) { + this.embed.unload().then(done); + } + + // Vimeo does not always return + setTimeout(done, 200); + } + } + + /** + * Check for support for a mime type (HTML5 only) + * @param {String} type - Mime type + */ + supports(type) { + return support.mime.call(this, type); + } + + /** + * Check for support + * @param {String} type - Player type (audio/video) + * @param {String} provider - Provider (html5/youtube/vimeo) + * @param {Boolean} inline - Where player has `playsinline` sttribute + */ + static supported(type, provider, inline) { + return support.check(type, provider, inline); + } + + /** + * Load an SVG sprite into the page + * @param {String} url - URL for the SVG sprite + * @param {String} [id] - Unique ID + */ + static loadSprite(url, id) { + return loadSprite(url, id); + } + + /** + * Setup multiple instances + * @param {*} selector + * @param {Object} options + */ + static setup(selector, options = {}) { + let targets = null; + + if (is.string(selector)) { + targets = Array.from(document.querySelectorAll(selector)); + } else if (is.nodeList(selector)) { + targets = Array.from(selector); + } else if (is.array(selector)) { + targets = selector.filter(is.element); + } + + if (is.empty(targets)) { + return null; + } + + return targets.map(t => new Plyr(t, options)); + } } Plyr.defaults = cloneDeep(defaults); |