diff options
author | Sam Potts <me@sampotts.me> | 2017-11-18 19:33:01 +1100 |
---|---|---|
committer | Sam Potts <me@sampotts.me> | 2017-11-18 19:33:01 +1100 |
commit | 5a244b7fedf185842b04f817eb07f49ae589d4a6 (patch) | |
tree | 86bcff6d5b6f9843df0d4f1ad3da926569066662 /src/js | |
parent | 4dca4bf93c5cfcb8adb40b78528867de271b8361 (diff) | |
parent | 6984d6fb1606a71edd35ac043ac1116b6de8e98b (diff) | |
download | plyr-5a244b7fedf185842b04f817eb07f49ae589d4a6.tar.lz plyr-5a244b7fedf185842b04f817eb07f49ae589d4a6.tar.xz plyr-5a244b7fedf185842b04f817eb07f49ae589d4a6.zip |
Merge branch 'develop' of https://github.com/Selz/plyr into develop
# Conflicts:
# dist/plyr.js
# dist/plyr.js.map
# src/js/controls.js
Diffstat (limited to 'src/js')
-rw-r--r-- | src/js/captions.js | 3 | ||||
-rw-r--r-- | src/js/controls.js | 169 | ||||
-rw-r--r-- | src/js/defaults.js | 19 | ||||
-rw-r--r-- | src/js/listeners.js | 58 | ||||
-rw-r--r-- | src/js/media.js | 5 | ||||
-rw-r--r-- | src/js/plugins/vimeo.js | 61 | ||||
-rw-r--r-- | src/js/plugins/youtube.js | 63 | ||||
-rw-r--r-- | src/js/plyr.js | 29 | ||||
-rw-r--r-- | src/js/ui.js | 157 | ||||
-rw-r--r-- | src/js/utils.js | 95 |
10 files changed, 386 insertions, 273 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index 247bf5db..2cab9414 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -79,8 +79,9 @@ const captions = { // Filter doesn't seem to work for a TextTrackList :-( Array.from(this.captions.tracks).forEach(track => { - if (track.language === this.captions.language.toLowerCase()) { + if (track.language.toLowerCase() === this.language.toLowerCase()) { this.captions.currentTrack = track; + console.warn(`Set current track to ${this.language}`); } }); }; diff --git a/src/js/controls.js b/src/js/controls.js index 0fb17237..fa2d6509 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -75,7 +75,7 @@ const controls = { const use = document.createElementNS(namespace, 'use'); const path = `${iconPath}-${type}`; - // If the new `href` attribute is supported, use that + // Set `href` attributes // https://github.com/sampotts/plyr/issues/460 // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href if ('href' in use) { @@ -118,6 +118,10 @@ const controls = { // Create a badge createBadge(text) { + if (utils.is.empty(text)) { + return null; + } + const badge = utils.createElement('span', { class: this.config.classNames.menu.value, }); @@ -322,6 +326,39 @@ const controls = { return container; }, + // Create a settings menu item + createMenuItem(value, list, type, title, badge = null, checked = false) { + const item = utils.createElement('li'); + + const label = utils.createElement('label', { + class: this.config.classNames.control, + }); + + const radio = utils.createElement( + 'input', + utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), { + type: 'radio', + name: `plyr-${type}`, + value, + checked, + class: 'plyr__sr-only', + }) + ); + + const faux = utils.createElement('span', { 'aria-hidden': true }); + + label.appendChild(radio); + label.appendChild(faux); + label.insertAdjacentHTML('beforeend', title); + + if (utils.is.htmlElement(badge)) { + label.appendChild(badge); + } + + item.appendChild(label); + list.appendChild(item); + }, + // Update hover tooltip for seeking updateSeekTooltip(event) { // Bail if setting not true @@ -356,7 +393,7 @@ const controls = { } // Display the time a click would seek to - ui.updateTimeDisplay.call(this, this.duration / 100 * percent, this.elements.display.seekTooltip); + ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent); // Set position this.elements.display.seekTooltip.style.left = `${percent}%`; @@ -393,6 +430,7 @@ const controls = { // Set the YouTube quality menu // TODO: Support for HTML5 setQualityMenu(options) { + const type = 'quality'; const list = this.elements.settings.panes.quality.querySelector('ul'); // Set options if passed and filter based on config @@ -404,7 +442,7 @@ const controls = { // Toggle the pane and tab const toggle = !utils.is.empty(this.options.quality) && this.type === 'youtube'; - controls.toggleTab.call(this, 'quality', toggle); + controls.toggleTab.call(this, type, toggle); // If we're hiding, nothing more to do if (!toggle) { @@ -446,35 +484,18 @@ const controls = { return controls.createBadge.call(this, label); }; - this.options.quality.forEach(quality => { - const item = utils.createElement('li'); - - const label = utils.createElement('label', { - class: this.config.classNames.control, - }); - - const radio = utils.createElement( - 'input', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.quality), { - type: 'radio', - name: 'plyr-quality', - value: quality, - }) + this.options.quality.forEach(quality => + controls.createMenuItem.call( + this, + quality, + list, + type, + controls.getLabel.call(this, 'quality', quality), + getBadge(quality) + ) ); - label.appendChild(radio); - label.appendChild(document.createTextNode(controls.getLabel.call(this, 'quality', quality))); - - const badge = getBadge(quality); - if (utils.is.htmlElement(badge)) { - label.appendChild(badge); - } - - item.appendChild(label); - list.appendChild(item); - }); - - controls.updateSetting.call(this, 'quality', list); + controls.updateSetting.call(this, type, list); }, // Translate a value into a nice label @@ -576,7 +597,7 @@ const controls = { }, // Set the looping options - setLoopMenu() { + /* setLoopMenu() { const options = ['start', 'end', 'all', 'reset']; const list = this.elements.settings.panes.loop.querySelector('ul'); @@ -612,7 +633,7 @@ const controls = { item.appendChild(button); list.appendChild(item); }); - }, + }, */ // Get current selected caption language // TODO: rework this to user the getter in the API? @@ -634,11 +655,13 @@ const controls = { // Set a list of available captions languages setCaptionsMenu() { + // TODO: Captions or language? Currently it's mixed + const type = 'captions'; const list = this.elements.settings.panes.captions.querySelector('ul'); // Toggle the pane and tab const toggle = !utils.is.empty(this.captions.tracks); - controls.toggleTab.call(this, 'captions', toggle); + controls.toggleTab.call(this, type, toggle); // Empty the menu utils.emptyElement(list); @@ -651,7 +674,6 @@ const controls = { // Re-map the tracks into just the data we need const tracks = Array.from(this.captions.tracks).map(track => ({ language: track.language, - badge: true, label: !utils.is.empty(track.label) ? track.label : track.language.toUpperCase(), })); @@ -663,41 +685,24 @@ const controls = { // Generate options tracks.forEach(track => { - const item = utils.createElement('li'); - - const label = utils.createElement('label', { - class: this.config.classNames.control, - }); - - const radio = utils.createElement( - 'input', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.language), { - type: 'radio', - name: 'plyr-language', - value: track.language, - }) + controls.createMenuItem.call( + this, + track.language, + list, + 'language', + track.label || track.language, + controls.createBadge.call(this, track.language.toUpperCase()), + track.language.toLowerCase() === this.captions.language.toLowerCase() ); - - if (track.language.toLowerCase() === this.captions.language.toLowerCase()) { - radio.checked = true; - } - - label.appendChild(radio); - label.appendChild(document.createTextNode(track.label || track.language)); - - if (track.badge) { - label.appendChild(controls.createBadge.call(this, track.language.toUpperCase())); - } - - item.appendChild(label); - list.appendChild(item); }); - controls.updateSetting.call(this, 'captions', list); + controls.updateSetting.call(this, type, list); }, // Set a list of available captions languages setSpeedMenu(options) { + const type = 'speed'; + // Set options if passed and filter based on config if (utils.is.array(options)) { this.options.speed = options.filter(speed => this.config.speed.options.includes(speed)); @@ -707,7 +712,7 @@ const controls = { // Toggle the pane and tab const toggle = !utils.is.empty(this.options.speed); - controls.toggleTab.call(this, 'speed', toggle); + controls.toggleTab.call(this, type, toggle); // If we're hiding, nothing more to do if (!toggle) { @@ -725,39 +730,23 @@ const controls = { utils.emptyElement(list); // Create items - this.options.speed.forEach(speed => { - const item = utils.createElement('li'); - - const label = utils.createElement('label', { - class: this.config.classNames.control, - }); - - const radio = utils.createElement( - 'input', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.speed), { - type: 'radio', - name: 'plyr-speed', - value: speed, - }) + this.options.speed.forEach(speed => + controls.createMenuItem.call(this, speed, list, type, controls.getLabel.call(this, 'speed', speed)) ); - label.appendChild(radio); - label.insertAdjacentHTML('beforeend', controls.getLabel.call(this, 'speed', speed)); - item.appendChild(label); - list.appendChild(item); - }); - - controls.updateSetting.call(this, 'speed', list); + controls.updateSetting.call(this, type, list); }, // Show/hide menu toggleMenu(event) { const { form } = this.elements.settings; const button = this.elements.buttons.settings; - const show = utils.is.boolean(event) ? event : form && form.getAttribute('aria-hidden') === 'true'; + const show = utils.is.boolean(event) + ? event + : utils.is.htmlElement(form) && form.getAttribute('aria-hidden') === 'true'; if (utils.is.event(event)) { - const isMenuItem = form && form.contains(event.target); + const isMenuItem = utils.is.htmlElement(form) && form.contains(event.target); const isButton = event.target === this.elements.buttons.settings; // If the click was inside the form or if the click @@ -774,10 +763,11 @@ const controls = { } // Set form and button attributes - if (button) { + if (utils.is.htmlElement(button)) { button.setAttribute('aria-expanded', show); } - if (form) { + + if (utils.is.htmlElement(form)) { form.setAttribute('aria-hidden', !show); if (show) { @@ -885,6 +875,9 @@ const controls = { pane.setAttribute('aria-hidden', !show); tab.setAttribute('aria-expanded', show); pane.removeAttribute('tabindex'); + + // Focus the first item + pane.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]')[0].focus(); }, // Build the default HTML diff --git a/src/js/defaults.js b/src/js/defaults.js index cf6bc344..c9a81842 100644 --- a/src/js/defaults.js +++ b/src/js/defaults.js @@ -22,13 +22,20 @@ const defaults = { // Pass a custom duration duration: null, - // Display the media duration + // Display the media duration on load in the current time position + // If you have opted to display both duration and currentTime, this is ignored displayDuration: true, + // Invert the current time to be a countdown + invertTime: true, + + // Clicking the currentTime inverts it's value to show time left rather than elapsed + toggleInvert: true, + // Aspect ratio (for embeds) ratio: '16:9', - // Click video to play + // Click video container to play/pause clickToPlay: true, // Auto hide the controls @@ -203,7 +210,7 @@ const defaults = { 'exitfullscreen', 'captionsenabled', 'captionsdisabled', - 'captionchange', + 'languagechange', 'controlshidden', 'controlsshown', 'ready', @@ -276,6 +283,7 @@ const defaults = { isIos: 'plyr--is-ios', isTouch: 'plyr--is-touch', uiSupported: 'plyr--full-ui', + noTransition: 'plyr--no-transition', menu: { value: 'plyr__menu__value', badge: 'plyr__badge', @@ -298,6 +306,11 @@ const defaults = { }, tabFocus: 'plyr__tab-focus', }, + + // API keys + keys: { + google: null, + }, }; export default defaults; diff --git a/src/js/listeners.js b/src/js/listeners.js index 3d9334ce..f93d4242 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -101,7 +101,6 @@ const listeners = { case 75: // Space and K key if (!held) { - this.console.warn('togglePlay', event.type); this.togglePlay(); } break; @@ -119,7 +118,7 @@ const listeners = { case 77: // M key if (!held) { - this.muted = 'toggle'; + this.muted = !this.muted; } break; @@ -145,6 +144,11 @@ const listeners = { } break; + case 76: + // L key + this.loop = !this.loop; + break; + /* case 73: this.setLoop('start'); break; @@ -205,7 +209,7 @@ const listeners = { // Toggle controls on mouse events and entering fullscreen utils.on( this.elements.container, - 'mouseenter mouseleave mousemove touchstart touchend touchcancel touchmove enterfullscreen', + 'click mouseenter mouseleave mousemove touchmove enterfullscreen exitfullscreen', event => { this.toggleControls(event); } @@ -213,11 +217,11 @@ const listeners = { } // Handle user exiting fullscreen by escaping etc - if (fullscreen.enabled) { + /* if (fullscreen.enabled) { utils.on(document, fullscreen.eventType, event => { this.toggleFullscreen(event); }); - } + } */ }, // Listen for media events @@ -226,7 +230,7 @@ const listeners = { utils.on(this.media, 'timeupdate seeking', event => ui.timeUpdate.call(this, event)); // Display duration - utils.on(this.media, 'durationchange loadedmetadata', event => ui.displayDuration.call(this, event)); + utils.on(this.media, 'durationchange loadedmetadata', event => ui.durationUpdate.call(this, event)); // Handle the media finishing utils.on(this.media, 'ended', () => { @@ -314,7 +318,7 @@ const listeners = { }); // Caption language change - utils.on(this.media, 'captionchange', () => { + utils.on(this.media, 'languagechange', () => { // Save to storage storage.set.call(this, { language: this.language }); }); @@ -337,7 +341,14 @@ const listeners = { // Proxy events to container // Bubble up key events for Edge utils.on(this.media, this.config.events.concat(['keyup', 'keydown']).join(' '), event => { - utils.dispatchEvent.call(this, this.elements.container, event.type, true); + let detail = {}; + + // Get error details from media + if (event.type === 'error') { + detail = this.media.error; + } + + utils.dispatchEvent.call(this, this.elements.container, event.type, true, detail); }); }, @@ -452,11 +463,16 @@ const listeners = { controls.showTab.call(this, event); // Settings menu items - use event delegation as items are added/removed - // Settings - Language if (utils.matches(event.target, this.config.selectors.inputs.language)) { + // Settings - Language proxy(event, 'language', () => { - this.toggleCaptions(true); - this.language = event.target.value.toLowerCase(); + const language = event.target.value; + + this.toggleCaptions(!utils.is.empty(language)); + + if (!utils.is.empty(language)) { + this.language = event.target.value.toLowerCase(); + } }); } else if (utils.matches(event.target, this.config.selectors.inputs.quality)) { // Settings - Quality @@ -468,7 +484,7 @@ const listeners = { proxy(event, 'speed', () => { this.speed = parseFloat(event.target.value); }); - } else if (utils.matches(event.target, this.config.selectors.buttons.loop)) { + } /* else if (utils.matches(event.target, this.config.selectors.buttons.loop)) { // Settings - Looping // TODO: use toggle buttons proxy(event, 'loop', () => { @@ -477,7 +493,7 @@ const listeners = { this.console.warn('Set loop'); }); - } + } */ }); // Seek @@ -487,6 +503,20 @@ const listeners = { }) ); + // Current time invert + // Only if one time element is used for both currentTime and duration + if (this.config.toggleInvert && !utils.is.htmlElement(this.elements.display.duration)) { + utils.on(this.elements.display.currentTime, 'click', () => { + // Do nothing if we're at the start + if (this.currentTime === 0) { + return; + } + + this.config.invertTime = !this.config.invertTime; + ui.timeUpdate.call(this); + }); + } + // Volume utils.on(this.elements.inputs.volume, inputEvent, event => proxy(event, 'volume', () => { @@ -522,7 +552,7 @@ const listeners = { // TODO: Check we need capture here utils.on( this.elements.controls, - 'focus blur', + 'focusin focusout', event => { this.toggleControls(event); }, diff --git a/src/js/media.js b/src/js/media.js index 7904bd5b..2f2146a2 100644 --- a/src/js/media.js +++ b/src/js/media.js @@ -65,7 +65,6 @@ const media = { utils.wrap(this.media, this.elements.wrapper); } - // Embeds if (this.isEmbed) { switch (this.type) { case 'youtube': @@ -79,9 +78,9 @@ const media = { default: break; } + } else { + ui.setTitle.call(this); } - - ui.setTitle.call(this); }, // Cancel current network requests diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index 18ef1d38..10c0fc62 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -37,7 +37,8 @@ const vimeo = { setAspectRatio(input) { const ratio = utils.is.string(input) ? input.split(':') : this.config.ratio.split(':'); const padding = 100 / ratio[0] * ratio[1]; - const offset = (300 - padding) / 6; + const height = 200; + const offset = (height - padding) / (height / 50); this.elements.wrapper.style.paddingBottom = `${padding}%`; this.media.style.transform = `translateY(-${offset}%)`; }, @@ -55,6 +56,7 @@ const vimeo = { title: false, speed: true, transparent: 0, + gesture: 'media', }; const params = utils.buildUrlParameters(options); const id = utils.parseVimeoId(player.embedId); @@ -70,23 +72,27 @@ const vimeo = { // https://github.com/vimeo/player.js player.embed = new window.Vimeo.Player(iframe); + player.media.paused = true; + player.media.currentTime = 0; + // Create a faux HTML5 API using the Vimeo API player.media.play = () => { - player.embed.play(); - player.media.paused = false; + player.embed.play().then(() => { + player.media.paused = false; + }); }; player.media.pause = () => { - player.embed.pause(); - player.media.paused = true; + player.embed.pause().then(() => { + player.media.paused = true; + }); }; player.media.stop = () => { - player.embed.stop(); - player.media.paused = true; + player.embed.stop().then(() => { + player.media.paused = true; + player.currentTime = 0; + }); }; - player.media.paused = true; - player.media.currentTime = 0; - // Seeking let { currentTime } = player.media; Object.defineProperty(player.media, 'currentTime', { @@ -121,9 +127,10 @@ const vimeo = { return speed; }, set(input) { - speed = input; - player.embed.setPlaybackRate(input); - utils.dispatchEvent.call(player, player.media, 'ratechange'); + player.embed.setPlaybackRate(input).then(() => { + speed = input; + utils.dispatchEvent.call(player, player.media, 'ratechange'); + }); }, }); @@ -134,9 +141,10 @@ const vimeo = { return volume; }, set(input) { - volume = input; - player.embed.setVolume(input); - utils.dispatchEvent.call(player, player.media, 'volumechange'); + player.embed.setVolume(input).then(() => { + volume = input; + utils.dispatchEvent.call(player, player.media, 'volumechange'); + }); }, }); @@ -148,9 +156,11 @@ const vimeo = { }, set(input) { const toggle = utils.is.boolean(input) ? input : false; - muted = toggle; - player.embed.setVolume(toggle ? 0 : player.config.volume); - utils.dispatchEvent.call(player, player.media, 'volumechange'); + + player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => { + muted = toggle; + utils.dispatchEvent.call(player, player.media, 'volumechange'); + }); }, }); @@ -161,8 +171,11 @@ const vimeo = { return loop; }, set(input) { - loop = utils.is.boolean(input) ? input : player.config.loop.active; - player.embed.setLoop(loop); + const toggle = utils.is.boolean(input) ? input : player.config.loop.active; + + player.embed.setLoop(toggle).then(() => { + loop = toggle; + }); }, }); @@ -191,6 +204,7 @@ const vimeo = { // Get title player.embed.getVideoTitle().then(title => { player.config.title = title; + ui.setTitle.call(this); }); // Get current time @@ -269,6 +283,11 @@ const vimeo = { utils.dispatchEvent.call(player, player.media, 'ended'); }); + player.embed.on('error', detail => { + player.media.error = detail; + utils.dispatchEvent.call(player, player.media, 'error'); + }); + // Rebuild UI window.setTimeout(() => ui.build.call(player), 0); }, diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index da127bed..a46773a0 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -23,6 +23,22 @@ const youtube = { // Set ID this.media.setAttribute('id', utils.generateId(this.type)); + // Get the media title via Google API + const key = this.config.keys.google; + if (utils.is.string(key) && !utils.is.empty(key)) { + const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&key=${key}&fields=items(snippet(title))&part=snippet`; + + fetch(url) + .then(response => (response.ok ? response.json() : null)) + .then(result => { + if (result !== null && utils.is.object(result)) { + this.config.title = result.items[0].snippet.title; + ui.setTitle.call(this); + } + }) + .catch(() => {}); + } + // Setup API if (utils.is.object(window.YT)) { youtube.ready.call(this, videoId); @@ -81,10 +97,47 @@ const youtube = { }, events: { onError(event) { - utils.dispatchEvent.call(player, player.media, 'error', true, { + // If we've already fired an error, don't do it again + // YouTube fires onError twice + if (utils.is.object(player.media.error)) { + return; + } + + const detail = { code: event.data, - embed: event.target, - }); + }; + + // 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(event) { // Get the instance @@ -207,7 +260,9 @@ const youtube = { } // Set title - player.config.title = instance.getVideoData().title; + if (utils.is.function(instance.getVideoData)) { + player.config.title = instance.getVideoData().title; + } // Set the tabindex to avoid focus entering iframe if (player.supported.ui) { diff --git a/src/js/plyr.js b/src/js/plyr.js index aed3da44..87fb014a 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -669,7 +669,7 @@ class Plyr { const language = input.toLowerCase(); // If nothing to change, bail - if (this.captions.language === language) { + if (this.language === language) { return; } @@ -680,7 +680,7 @@ class Plyr { this.captions.language = language; // Trigger an event - utils.dispatchEvent.call(this, this.media, 'captionchange'); + utils.dispatchEvent.call(this, this.media, 'languagechange'); // Clear caption captions.set.call(this); @@ -797,31 +797,28 @@ class Plyr { // Show the player controls in fullscreen mode toggleControls(toggle) { - const player = this; - // We need controls of course... if (!utils.is.htmlElement(this.elements.controls)) { - return player; + return this; } // Don't hide if config says not to, it's audio, or not ready or loading if (!this.supported.ui || !this.config.hideControls || this.type === 'audio') { - return player; + return this; } let delay = 0; let show = toggle; let isEnterFullscreen = false; - const loading = utils.hasClass(this.elements.container, this.config.classNames.loading); - // Default to false if no boolean + // 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'; // Whether to show controls - show = ['mousemove', 'touchstart', 'mouseenter', 'focus'].includes(toggle.type); + show = ['click', 'mousemove', 'touchmove', 'mouseenter', 'focusin'].includes(toggle.type); // Delay hiding on move events if (['mousemove', 'touchmove'].includes(toggle.type)) { @@ -829,8 +826,9 @@ class Plyr { } // Delay a little more for keyboard users - if (toggle.type === 'focus') { + if (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); @@ -841,7 +839,7 @@ class Plyr { window.clearTimeout(this.timers.hover); // If the mouse is not over the controls, set a timeout to hide them - if (show || this.media.paused || loading) { + if (show || this.media.paused || this.loading) { // Check if controls toggled const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false); @@ -851,8 +849,8 @@ class Plyr { } // Always show controls when paused or if touch - if (this.media.paused || loading) { - return player; + if (this.media.paused || this.loading) { + return this; } // Delay for hiding on touch @@ -870,6 +868,11 @@ class Plyr { return; } + // Restore transition behaviour + if (!utils.hasClass(this.elements.container, this.config.classNames.hideControls)) { + utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, false); + } + // Check if controls toggled const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, true); diff --git a/src/js/ui.js b/src/js/ui.js index 0ecc5621..fe8fac0f 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -15,7 +15,7 @@ const ui = { }, // Toggle native HTML5 media controls - toggleNativeControls(toggle) { + toggleNativeControls(toggle = false) { if (toggle && this.isHTML5) { this.media.setAttribute('controls', ''); } else { @@ -96,31 +96,8 @@ const ui = { // Ready event at end of execution stack utils.dispatchEvent.call(this, this.media, 'ready'); - // Autoplay - // TODO: check we still need this? - /* if (this.isEmbed && this.config.autoplay) { - this.play(); - } */ - }, - - // Show the duration on metadataloaded - displayDuration() { - if (!this.supported.ui) { - return; - } - - // If there's only one time display, display duration there - if (!this.elements.display.duration && this.config.displayDuration && this.paused) { - ui.updateTimeDisplay.call(this, this.duration, this.elements.display.currentTime); - } - - // If there's a duration element, update content - if (this.elements.display.duration) { - ui.updateTimeDisplay.call(this, this.duration, this.elements.display.duration); - } - - // Update the tooltip (if visible) - controls.updateSeekTooltip.call(this); + // Set the title + ui.setTitle.call(this); }, // Setup aria attribute for play and iframe title @@ -137,13 +114,10 @@ const ui = { } // If there's a play button, set label - if (this.supported.ui) { - if (utils.is.htmlElement(this.elements.buttons.play)) { - this.elements.buttons.play.setAttribute('aria-label', label); - } - if (utils.is.htmlElement(this.elements.buttons.playLarge)) { - this.elements.buttons.playLarge.setAttribute('aria-label', label); - } + if (utils.is.nodeList(this.elements.buttons.play)) { + Array.from(this.elements.buttons.play).forEach(button => { + button.setAttribute('aria-label', label); + }); } // Set iframe title @@ -171,23 +145,6 @@ const ui = { this.toggleControls(this.paused); }, - // Update volume UI and storage - updateVolume() { - if (!this.supported.ui) { - return; - } - - // Update range - if (utils.is.htmlElement(this.elements.inputs.volume)) { - ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); - } - - // Update checkbox for mute state - if (utils.is.htmlElement(this.elements.buttons.mute)) { - utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); - } - }, - // Check if media is loading checkLoading(event) { this.loading = event.type === 'waiting'; @@ -205,8 +162,25 @@ const ui = { }, this.loading ? 250 : 0); }, + // Update volume UI and storage + updateVolume() { + if (!this.supported.ui) { + return; + } + + // Update range + if (utils.is.htmlElement(this.elements.inputs.volume)) { + ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); + } + + // Update checkbox for mute state + if (utils.is.htmlElement(this.elements.buttons.mute)) { + utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0); + } + }, + // Update seek value and lower fill - setRange(target, value) { + setRange(target, value = 0) { if (!utils.is.htmlElement(target)) { return; } @@ -220,9 +194,8 @@ const ui = { // Set <progress> value setProgress(target, input) { - // Default to 0 - const value = !utils.is.undefined(input) ? input : 0; - const progress = !utils.is.undefined(target) ? target : this.elements.display.buffer; + const value = utils.is.number(input) ? input : 0; + const progress = utils.is.htmlElement(target) ? target : this.elements.display.buffer; // Update value and label if (utils.is.htmlElement(progress)) { @@ -238,7 +211,7 @@ const ui = { // Update <progress> elements updateProgress(event) { - if (!this.supported.ui) { + if (!this.supported.ui || !utils.is.event(event)) { return; } @@ -286,41 +259,49 @@ const ui = { }, // Update the displayed time - updateTimeDisplay(value, element) { - // Bail if there's no duration display - if (!utils.is.htmlElement(element)) { - return null; + 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.htmlElement(target) || !utils.is.number(time)) { + return; } - // Fallback to 0 - const time = !Number.isNaN(value) ? value : 0; + // Format time component to add leading zero + const format = value => `0${value}`.slice(-2); - let secs = parseInt(time % 60, 10); - let mins = parseInt((time / 60) % 60, 10); - const hours = parseInt((time / 60 / 60) % 60, 10); + // Helpers + const getHours = value => parseInt((value / 60 / 60) % 60, 10); + const getMinutes = value => parseInt((value / 60) % 60, 10); + const getSeconds = value => parseInt(value % 60, 10); - // Do we need to display hours? - const displayHours = parseInt((this.duration / 60 / 60) % 60, 10) > 0; - - // Ensure it's two digits. For example, 03 rather than 3. - secs = `0${secs}`.slice(-2); - mins = `0${mins}`.slice(-2); + // Breakdown to hours, mins, secs + let hours = getHours(time); + const mins = getMinutes(time); + const secs = getSeconds(time); - // Generate display - const display = `${(displayHours ? `${hours}:` : '') + mins}:${secs}`; + // Do we need to display hours? + if (getHours(this.duration) > 0) { + hours = `${hours}:`; + } else { + hours = ''; + } // Render - // eslint-disable-next-line - element.textContent = display; - - // Return for looping - return display; + // eslint-disable-next-line no-param-reassign + target.textContent = `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; }, // 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.htmlElement(this.elements.display.duration) && this.config.invertTime; + // Duration - ui.updateTimeDisplay.call(this, this.currentTime, this.elements.display.currentTime); + 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) { @@ -330,6 +311,26 @@ const ui = { // Playing progress ui.updateProgress.call(this, event); }, + + // Show the duration on metadataloaded + durationUpdate() { + if (!this.supported.ui) { + return; + } + + // If there's only one time display, display duration there + if (!utils.is.htmlElement(this.elements.display.duration) && this.config.displayDuration && this.paused) { + ui.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration); + } + + // If there's a duration element, update content + if (utils.is.htmlElement(this.elements.display.duration)) { + ui.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); + } + + // Update the tooltip (if visible) + controls.updateSeekTooltip.call(this); + }, }; export default ui; diff --git a/src/js/utils.js b/src/js/utils.js index a635e021..bb576576 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -31,6 +31,9 @@ const utils = { htmlElement(input) { return !this.undefined(input) && input instanceof HTMLElement; }, + textNode(input) { + return this.getConstructor(input) === Text; + }, event(input) { return !this.undefined(input) && input instanceof Event; }, @@ -49,8 +52,8 @@ const utils = { return ( input === null || typeof input === 'undefined' || - ((this.string(input) || this.array(input) || this.nodeList(input)) && input.length === 0) || - (this.object(input) && Object.keys(input).length === 0) + ((this.string(input) || this.array(input) || this.nodeList(input)) && !input.length) || + (this.object(input) && !Object.keys(input).length) ); }, getConstructor(input) { @@ -100,12 +103,12 @@ const utils = { // Load an external SVG sprite loadSprite(url, id) { - if (typeof url !== 'string') { + if (!utils.is.string(url)) { return; } const prefix = 'cache-'; - const hasId = typeof id === 'string'; + const hasId = utils.is.string(id); let isCached = false; function updateSprite(data) { @@ -134,34 +137,30 @@ const utils = { if (isCached) { const data = JSON.parse(cached); updateSprite.call(container, data.content); + return; } } - // ReSharper disable once InconsistentNaming - const xhr = new XMLHttpRequest(); - - // XHR for Chrome/Firefox/Opera/Safari - if ('withCredentials' in xhr) { - xhr.open('GET', url, true); - } else { - return; - } - - // Once loaded, inject to container and body - xhr.onload = () => { - if (support.storage) { - window.localStorage.setItem( - prefix + id, - JSON.stringify({ - content: xhr.responseText, - }) - ); - } + // Get the sprite + fetch(url) + .then(response => (response.ok ? response.text() : null)) + .then(text => { + if (text === null) { + return; + } - updateSprite.call(container, xhr.responseText); - }; + if (support.storage) { + window.localStorage.setItem( + prefix + id, + JSON.stringify({ + content: text, + }) + ); + } - xhr.send(); + updateSprite.call(container, text); + }) + .catch(() => {}); } }, @@ -210,22 +209,6 @@ const utils = { }); }, - // Remove an element - removeElement(element) { - if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) { - return null; - } - - element.parentNode.removeChild(element); - - return element; - }, - - // Inaert an element after another - insertAfter(element, target) { - target.parentNode.insertBefore(element, target.nextSibling); - }, - // Create a DocumentFragment createElement(type, attributes, text) { // Create a new <element> @@ -245,12 +228,28 @@ const utils = { 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 an element + removeElement(element) { + if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) { + return null; + } + + element.parentNode.removeChild(element); + + return element; + }, + // Remove all child elements emptyElement(element) { let { length } = element.childNodes; @@ -442,9 +441,9 @@ const utils = { // Trap focus inside container trapFocus() { - const tabbables = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]'); - const first = tabbables[0]; - const last = tabbables[tabbables.length - 1]; + const focusable = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; utils.on( this.elements.container, @@ -525,7 +524,7 @@ const utils = { }, // Trigger event - dispatchEvent(element, type, bubbles, properties) { + dispatchEvent(element, type, bubbles, detail) { // Bail if no element if (!element || !type) { return; @@ -534,7 +533,7 @@ const utils = { // Create and dispatch the event const event = new CustomEvent(type, { bubbles: utils.is.boolean(bubbles) ? bubbles : false, - detail: Object.assign({}, properties, { + detail: Object.assign({}, detail, { plyr: this instanceof Plyr ? this : null, }), }); |