From 1cc2930dc0b81183bc47442f5ad9b5d8df94cc5f Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Sat, 4 Nov 2017 14:25:28 +1100 Subject: ES6-ified --- src/js/captions.js | 212 ++ src/js/controls.js | 1177 ++++++++ src/js/defaults.js | 301 ++ src/js/fullscreen.js | 129 + src/js/listeners.js | 569 ++++ src/js/media.js | 109 + src/js/plugins/vimeo.js | 165 ++ src/js/plugins/youtube.js | 256 ++ src/js/plyr.js | 5832 ++++--------------------------------- src/js/source.js | 162 ++ src/js/storage.js | 56 + src/js/support.js | 174 ++ src/js/types.js | 10 + src/js/ui.js | 381 +++ src/js/utils.js | 667 +++++ src/less/base.less | 1 - src/less/components/controls.less | 2 +- src/less/components/embed.less | 7 + src/less/components/sliders.less | 4 +- src/less/components/video.less | 4 + 20 files changed, 4962 insertions(+), 5256 deletions(-) create mode 100644 src/js/captions.js create mode 100644 src/js/controls.js create mode 100644 src/js/defaults.js create mode 100644 src/js/fullscreen.js create mode 100644 src/js/listeners.js create mode 100644 src/js/media.js create mode 100644 src/js/plugins/vimeo.js create mode 100644 src/js/plugins/youtube.js create mode 100644 src/js/source.js create mode 100644 src/js/storage.js create mode 100644 src/js/support.js create mode 100644 src/js/types.js create mode 100644 src/js/ui.js create mode 100644 src/js/utils.js (limited to 'src') diff --git a/src/js/captions.js b/src/js/captions.js new file mode 100644 index 00000000..ed175530 --- /dev/null +++ b/src/js/captions.js @@ -0,0 +1,212 @@ +// ========================================================================== +// Plyr Captions +// ========================================================================== + +import support from './support'; +import utils from './utils'; +import controls from './controls'; + +const captions = { + // Setup captions + setup() { + // Requires UI support + if (!this.supported.ui) { + return; + } + + // Set default language if not set + if (!utils.is.empty(this.storage.language)) { + this.captions.language = this.storage.language; + } else if (utils.is.empty(this.captions.language)) { + this.captions.language = this.config.captions.language.toLowerCase(); + } + + // Set captions enabled state if not set + if (!utils.is.boolean(this.captions.enabled)) { + if (!utils.is.empty(this.storage.language)) { + this.captions.enabled = this.storage.captions; + } else { + this.captions.enabled = this.config.captions.active; + } + } + + // Only Vimeo and HTML5 video supported at this point + if (!['video', 'vimeo'].includes(this.type) || (this.type === 'video' && !support.textTracks)) { + this.captions.tracks = null; + + // Clear menu and hide + if (this.config.controls.includes('settings') && this.config.settings.includes('captions')) { + controls.setCaptionsMenu.call(this); + } + + return; + } + + // Inject the container + if (!utils.is.htmlElement(this.elements.captions)) { + this.elements.captions = utils.createElement( + 'div', + utils.getAttributesFromSelector(this.config.selectors.captions) + ); + utils.insertAfter(this.elements.captions, this.elements.wrapper); + } + + // Get tracks from HTML5 + if (this.type === 'video') { + this.captions.tracks = this.media.textTracks; + } + + // Set the class hook + utils.toggleClass( + this.elements.container, + this.config.classNames.captions.enabled, + !utils.is.empty(this.captions.tracks) + ); + + // If no caption file exists, hide container for caption text + if (utils.is.empty(this.captions.tracks)) { + return; + } + + // Enable UI + captions.show.call(this); + + // Get a track + const setCurrentTrack = () => { + // Reset by default + this.captions.currentTrack = null; + + // Filter doesn't seem to work for a TextTrackList :-( + Array.from(this.captions.tracks).forEach(track => { + if (track.language === this.captions.language.toLowerCase()) { + this.captions.currentTrack = track; + } + }); + }; + + // Get current track + setCurrentTrack(); + + // If we couldn't get the requested language, revert to default + if (!utils.is.track(this.captions.currentTrack)) { + const { language } = this.config.captions; + + // Reset to default + // We don't update user storage as the selected language could become available + this.captions.language = language; + + // Get fallback track + setCurrentTrack(); + + // If no match, disable captions + if (!utils.is.track(this.captions.currentTrack)) { + this.toggleCaptions(false); + } + + controls.updateSetting.call(this, 'captions'); + } + + // Setup HTML5 track rendering + if (this.type === 'video') { + // Turn off native caption rendering to avoid double captions + Array.from(this.captions.tracks).forEach(track => { + // Remove previous bindings (if we've changed source or language) + utils.off(track, 'cuechange', event => captions.setCue.call(this, event)); + + // Hide captions + track.mode = 'hidden'; + }); + + // Check if suported kind + const supported = + this.captions.currentTrack && ['captions', 'subtitles'].includes(this.captions.currentTrack.kind); + + if (utils.is.track(this.captions.currentTrack) && supported) { + utils.on(this.captions.currentTrack, 'cuechange', event => captions.setCue.call(this, event)); + + // If we change the active track while a cue is already displayed we need to update it + if (this.captions.currentTrack.activeCues && this.captions.currentTrack.activeCues.length > 0) { + controls.setCue.call(this, this.captions.currentTrack); + } + } + } else if (this.type === 'vimeo' && this.captions.active) { + this.embed.enableTextTrack(this.captions.language); + } + + // Set available languages in list + if (this.config.controls.includes('settings') && this.config.settings.includes('captions')) { + controls.setCaptionsMenu.call(this); + } + }, + + // Display active caption if it contains text + setCue(input) { + // Get the track from the event if needed + const track = utils.is.event(input) ? input.target : input; + const active = track.activeCues[0]; + + // Display a cue, if there is one + if (utils.is.cue(active)) { + captions.set.call(this, active.getCueAsHTML()); + } else { + captions.set.call(this); + } + + utils.dispatchEvent.call(this, this.media, 'cuechange'); + }, + + // Set the current caption + set(input) { + // Requires UI + if (!this.supported.ui) { + return; + } + + if (utils.is.htmlElement(this.elements.captions)) { + const content = utils.createElement('span'); + + // Empty the container + utils.emptyElement(this.elements.captions); + + // Default to empty + const caption = !utils.is.undefined(input) ? input : ''; + + // Set the span content + if (utils.is.string(caption)) { + content.textContent = caption.trim(); + } else { + content.appendChild(caption); + } + + // Set new caption text + this.elements.captions.appendChild(content); + } else { + this.warn('No captions element to render to'); + } + }, + + // Display captions container and button (for initialization) + show() { + // If there's no caption toggle, bail + if (!this.elements.buttons.captions) { + return; + } + + // Try to load the value from storage + let active = this.storage.captions; + + // Otherwise fall back to the default config + if (!utils.is.boolean(active)) { + ({ active } = this.captions); + } else { + this.captions.active = active; + } + + if (active) { + utils.toggleClass(this.elements.container, this.config.classNames.captions.active, true); + utils.toggleState(this.elements.buttons.captions, true); + } + }, +}; + +export default captions; diff --git a/src/js/controls.js b/src/js/controls.js new file mode 100644 index 00000000..1e10f2f2 --- /dev/null +++ b/src/js/controls.js @@ -0,0 +1,1177 @@ +// ========================================================================== +// Plyr controls +// ========================================================================== + +import support from './support'; +import utils from './utils'; + +const controls = { + // Webkit polyfill for lower fill range + updateRangeFill(target) { + // WebKit only + if (!this.browser.isWebkit) { + return; + } + + // Get range from event if event passed + const range = utils.is.event(target) ? target.target : target; + + // Needs to be a valid + if (!utils.is.htmlElement(range) || range.getAttribute('type') !== 'range') { + return; + } + + // Inject the stylesheet if needed + if (!utils.is.htmlElement(this.elements.styleSheet)) { + this.elements.styleSheet = utils.createElement('style'); + this.elements.container.appendChild(this.elements.styleSheet); + } + + const styleSheet = this.elements.styleSheet.sheet; + const percentage = range.value / range.max * 100; + const selector = `#${range.id}::-webkit-slider-runnable-track`; + const styles = `{ background-image: linear-gradient(to right, currentColor ${percentage}%, transparent ${percentage}%) }`; + + // Find old rule if it exists + const index = Array.from(styleSheet.rules).findIndex(rule => rule.selectorText === selector); + + // Remove old rule + if (index !== -1) { + styleSheet.deleteRule(index); + } + + // Insert new one + styleSheet.insertRule([selector, styles].join(' ')); + }, + + // Get icon URL + getIconUrl() { + return { + url: this.config.iconUrl, + absolute: this.config.iconUrl.indexOf('http') === 0 || this.browser.isIE, + }; + }, + + // Create icon + createIcon(type, attributes) { + const namespace = 'http://www.w3.org/2000/svg'; + const iconUrl = controls.getIconUrl.call(this); + const iconPath = `${!iconUrl.absolute ? iconUrl.url : ''}#${this.config.iconPrefix}`; + + // Create + const icon = document.createElementNS(namespace, 'svg'); + utils.setAttributes( + icon, + utils.extend(attributes, { + role: 'presentation', + }) + ); + + // Create the to reference sprite + const use = document.createElementNS(namespace, 'use'); + use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${iconPath}-${type}`); + + // Add to + icon.appendChild(use); + + return icon; + }, + + // Create hidden text label + createLabel(type) { + let text = this.config.i18n[type]; + + switch (type) { + case 'pip': + text = 'PIP'; + break; + + case 'airplay': + text = 'AirPlay'; + break; + + default: + break; + } + + return utils.createElement( + 'span', + { + class: this.config.classNames.hidden, + }, + text + ); + }, + + // Create a badge + createBadge(text) { + const badge = utils.createElement('span', { + class: this.config.classNames.menu.value, + }); + + badge.appendChild( + utils.createElement( + 'span', + { + class: this.config.classNames.menu.badge, + }, + text + ) + ); + + return badge; + }, + + // Create a inside button - end = player.core.updateTimeDisplay(player.config.loop.end, player.core.getElement('[data-loop__value="loopout"]')); - } else { - // Find the inside button - //end = document.querySelector('[data-loop__value="loopout"]').innerHTML = ''; - } - - if (player.config.loop.active) { - // TODO: Improve the design of the loop indicator and put styling in CSS where it's meant to be - //getElement('[data-menu="loop"]').innerHTML = start + ' - ' + end; - //getElement(player.config.selectors.progress.looped).style.position = 'absolute'; - //getElement(player.config.selectors.progress.looped).style.left = player.config.loopinPositionPercentage + '%'; - //getElement(player.config.selectors.progress.looped).style.width = (player.config.loopoutPositionPercentage - player.config.loopinPositionPercentage) + '%'; - //getElement(player.config.selectors.progress.looped).style.background = '#ffbb00'; - //getElement(player.config.selectors.progress.looped).style.height = '3px'; - //getElement(player.config.selectors.progress.looped).style.top = '3px'; - //getElement(player.config.selectors.progress.looped).style['border-radius'] = '100px'; - } else { - //getElement('[data-menu="loop"]').innerHTML = player.config.i18n.loopNone; - //getElement(player.config.selectors.progress.looped).style.width = '0px'; - }*/ - // Allow chaining - return player; - }; - - // Add common function to retrieve media source - Plyr.prototype.source = function(source) { - var player = this; + return this; + } - // If object or string, parse it - if (utils.is.object(source)) { - player.core.updateSource(source); - return player; - } + // Media source + set src(input) { + source.change.call(this, input); + } - // Return the current source - var url; + get src() { + let url; - switch (player.type) { + switch (this.type) { case 'youtube': - url = player.embed.getVideoUrl(); + url = this.embed.getVideoUrl(); break; case 'vimeo': - player.embed.getVideoUrl.then(function(value) { + this.embed.getVideoUrl.then(value => { url = value; }); break; - case 'soundcloud': - player.embed.getCurrentSound(function(object) { - url = object.permalink_url; - }); - break; - default: - url = player.media.currentSrc; + url = this.media.currentSrc; break; } return url; - }; + } - // Set or get poster - Plyr.prototype.poster = function(source) { - var player = this; + // Poster image + set poster(input) { + if (this.type !== 'video') { + this.warn('Poster can only be set on HTML5 video'); + return; + } - if (!utils.is.string(source)) { - return player.media.getAttribute('poster'); - } else if (player.type === 'video') { - player.media.setAttribute('poster', source); - } else { - player.core.warn('Poster can only be set on HTML5 video'); + if (utils.is.string(input)) { + this.media.setAttribute('poster', input); + } + } + + get poster() { + if (this.type !== 'video') { + return null; } - // Allow chaining - return player; - }; + return this.media.getAttribute('poster'); + } // Toggle captions - Plyr.prototype.toggleCaptions = function(show) { - var player = this; - + toggleCaptions(input) { // If there's no full support, or there's no caption toggle - if (!player.supported.ui || !player.elements.buttons.captions) { - return; + if (!this.supported.ui || !this.elements.buttons.captions) { + return this; } // If the method is called without parameter, toggle based on current value - if (!utils.is.boolean(show)) { - show = player.elements.container.className.indexOf(player.config.classNames.captions.active) === -1; - } + const show = utils.is.boolean(input) + ? input + : this.elements.container.className.indexOf(this.config.classNames.captions.active) === -1; // Nothing to change... - if (player.captions.enabled === show) { - return player; + if (this.captions.enabled === show) { + return this; } // Set global - player.captions.enabled = show; + this.captions.enabled = show; // Toggle state - utils.toggleState(player.elements.buttons.captions, player.captions.enabled); + utils.toggleState(this.elements.buttons.captions, this.captions.enabled); // Add class hook - utils.toggleClass(player.elements.container, player.config.classNames.captions.active, player.captions.enabled); + utils.toggleClass(this.elements.container, this.config.classNames.captions.active, this.captions.enabled); // Trigger an event - player.core.trigger(player.media, player.captions.enabled ? 'captionsenabled' : 'captionsdisabled'); + utils.dispatchEvent.call(this, this.media, this.captions.enabled ? 'captionsenabled' : 'captionsdisabled'); // Allow chaining - return player; - }; + return this; + } - // Set caption language - Plyr.prototype.setLanguage = function(language) { - var player = this; + // Caption language + set language(input) { + const player = this; // Nothing specified - if (utils.is.empty(language)) { - player.toggleCaptions(false); + if (utils.is.empty(input)) { + this.toggleCaptions(false); return player; } // Normalize - language = language.toLowerCase(); + const language = input.toLowerCase(); // If nothing to change, bail - if (player.captions.language === language) { + if (this.captions.language === language) { return player; } // Reset UI - player.toggleCaptions(true); + this.toggleCaptions(true); // Update config - player.captions.language = language; + this.captions.language = language; // Trigger an event - player.core.trigger(player.media, 'captionchange'); + utils.dispatchEvent.call(this, this.media, 'captionchange'); // Clear caption - player.core.setCaption(); + captions.setCaption.call(this); // Re-run setup - player.core.setupCaptions(); + captions.setup.call(this); // Allow chaining - return player; - }; + return this; + } - // Get current language - Plyr.prototype.getLanguage = function() { + get language() { return this.captions.language; - }; + } // Toggle fullscreen // Requires user input event - Plyr.prototype.toggleFullscreen = function(event) { - var player = this; - + toggleFullscreen(event) { // Check for native support - if (support.fullscreen) { + if (fullscreen.enabled) { // If it's a fullscreen change event, update the UI if (utils.is.event(event) && event.type === fullscreen.eventType) { - player.fullscreen.active = fullscreen.isFullScreen(player.elements.container); + this.fullscreen.active = fullscreen.isFullScreen(this.elements.container); } else { // Else it's a user request to enter or exit - if (!player.fullscreen.active) { + if (!this.fullscreen.active) { // Request full screen - fullscreen.requestFullScreen(player.elements.container); + fullscreen.requestFullScreen(this.elements.container); } else { // Bail from fullscreen fullscreen.cancelFullScreen(); } // Check if we're actually full screen (it could fail) - player.fullscreen.active = fullscreen.isFullScreen(player.elements.container); + this.fullscreen.active = fullscreen.isFullScreen(this.elements.container); - return; + return this; } } else { // Otherwise, it's a simple toggle - player.fullscreen.active = !player.fullscreen.active; + this.fullscreen.active = !this.fullscreen.active; // Add class hook utils.toggleClass( - player.elements.container, - player.config.classNames.fullscreen.fallback, - player.fullscreen.active + this.elements.container, + this.config.classNames.fullscreen.fallback, + this.fullscreen.active ); // Make sure we don't lose scroll position - if (player.fullscreen.active) { - scroll = { + if (this.fullscreen.active) { + scrollPosition = { x: window.pageXOffset || 0, y: window.pageYOffset || 0, }; } else { - window.scrollTo(scroll.x, scroll.y); + window.scrollTo(scrollPosition.x, scrollPosition.y); } // Bind/unbind escape key - document.body.style.overflow = player.fullscreen.active ? 'hidden' : ''; + document.body.style.overflow = this.fullscreen.active ? 'hidden' : ''; } // Set button state - if (player.elements.buttons && player.elements.buttons.fullscreen) { - utils.toggleState(player.elements.buttons.fullscreen, player.fullscreen.active); + if (this.elements.buttons && this.elements.buttons.fullscreen) { + utils.toggleState(this.elements.buttons.fullscreen, this.fullscreen.active); } // Trigger an event - player.core.trigger(player.media, player.fullscreen.active ? 'enterfullscreen' : 'exitfullscreen'); + utils.dispatchEvent.call(this, this.media, this.fullscreen.active ? 'enterfullscreen' : 'exitfullscreen'); - // Allow chaining - return player; - }; + return this; + } // Toggle picture-in-picture // TODO: update player with state, support, enabled // TODO: detect outside changes - Plyr.prototype.togglePictureInPicture = function(toggle) { - var player = this; - var states = { + togglePictureInPicture(input) { + const player = this; + const states = { pip: 'picture-in-picture', inline: 'inline', }; // Bail if no support - if (!player.core.support.pip) { - return; + if (!support.pip) { + return player; } // Toggle based on current state if not passed - if (!utils.is.boolean(toggle)) { - toggle = player.media.webkitPresentationMode === states.inline; - } + const toggle = utils.is.boolean(input) ? input : this.media.webkitPresentationMode === states.inline; // Toggle based on current state - player.media.webkitSetPresentationMode(toggle ? states.pip : states.inline); + this.media.webkitSetPresentationMode(toggle ? states.pip : states.inline); - // Allow chaining - return player; - }; + return this; + } // Trigger airplay // TODO: update player with state, support, enabled - Plyr.prototype.airPlay = function() { - var player = this; - + airPlay() { // Bail if no support - if (!player.core.support.airplay) { - return; + if (!support.airplay) { + return this; } // Show dialog - player.media.webkitShowPlaybackTargetPicker(); + this.media.webkitShowPlaybackTargetPicker(); - // Allow chaining - return player; - }; + return this; + } // Show the player controls in fullscreen mode - Plyr.prototype.toggleControls = function(toggle) { - var player = this; + toggleControls(toggle) { + const player = this; + + // We need controls of course... + if (!utils.is.htmlElement(this.elements.controls)) { + return player; + } // Don't hide if config says not to, it's audio, or not ready or loading - if (!player.supported.ui || !player.config.hideControls || player.type === 'audio') { - return; + if (!this.supported.ui || !this.config.hideControls || this.type === 'audio') { + return player; } - var delay = 0; - var show = toggle; - var isEnterFullscreen = false; - var loading = utils.hasClass(player.elements.container, player.config.classNames.loading); + 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 if (!utils.is.boolean(toggle)) { @@ -5536,10 +884,10 @@ isEnterFullscreen = toggle.type === 'enterfullscreen'; // Whether to show controls - show = utils.inArray(['mousemove', 'touchstart', 'mouseenter', 'focus'], toggle.type); + show = ['mousemove', 'touchstart', 'mouseenter', 'focus'].includes(toggle.type); // Delay hiding on move events - if (utils.inArray(['mousemove', 'touchmove'], toggle.type)) { + if (['mousemove', 'touchmove'].includes(toggle.type)) { delay = 2000; } @@ -5548,26 +896,26 @@ delay = 3000; } } else { - show = utils.hasClass(player.elements.container, player.config.classNames.hideControls); + show = utils.hasClass(this.elements.container, this.config.classNames.hideControls); } } // Clear timer every movement - window.clearTimeout(player.core.timers.hover); + window.clearTimeout(this.timers.hover); // If the mouse is not over the controls, set a timeout to hide them - if (show || player.media.paused || loading) { + if (show || this.media.paused || loading) { // Check if controls toggled - var toggled = utils.toggleClass(player.elements.container, player.config.classNames.hideControls, false); + const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false); // Trigger event if (toggled) { - player.core.trigger(player.media, 'controlsshown'); + utils.dispatchEvent.call(this, this.media, 'controlsshown'); } // Always show controls when paused or if touch - if (player.media.paused || loading) { - return; + if (this.media.paused || loading) { + return player; } // Delay for hiding on touch @@ -5578,71 +926,104 @@ // If toggle is false or if we're playing (regardless of toggle), // then set the timer to hide the controls - if (!show || !player.media.paused) { - player.core.timers.hover = window.setTimeout(function() { + if (!show || !this.media.paused) { + this.timers.hover = window.setTimeout(() => { // If the mouse is over the controls (and not entering fullscreen), bail - if ((player.elements.controls.pressed || player.elements.controls.hover) && !isEnterFullscreen) { + if ((this.elements.controls.pressed || this.elements.controls.hover) && !isEnterFullscreen) { return; } // Check if controls toggled - var toggled = utils.toggleClass(player.elements.container, player.config.classNames.hideControls, true); + const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, true); // Trigger event and close menu if (toggled) { - player.core.trigger(player.media, 'controlshidden'); - if (utils.inArray(player.config.controls, 'settings') && !utils.is.empty(player.config.settings)) { - player.core.toggleMenu(false); + 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); } - // Allow chaining - return player; - }; + return this; + } // Event listeners - Plyr.prototype.on = function(event, callback) { - var player = this; - - // Listen for events on container - utils.on(player.elements.container, event, callback); + on(event, callback) { + utils.on(this.elements.container, event, callback); - // Allow chaining - return player; - }; - - Plyr.prototype.off = function(event, callback) { - var player = this; + return this; + } - // Listen for events on container - utils.off(player.elements.container, event, callback); + off(event, callback) { + utils.off(this.elements.container, event, callback); - // Allow chaining - return player; - }; + return this; + } // Check for support - Plyr.prototype.supports = function(mimeType) { + supports(mimeType) { return support.mime(this, mimeType); - }; + } // 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 - Plyr.prototype.destroy = function(callback, restore) { - var player = this; + destroy(callback, soft = false) { + 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) { + utils.removeElement(this.elements.captions); + utils.removeElement(this.elements.controls); + utils.removeElement(this.elements.wrapper); + + // Clear for GC + this.elements.captions = null; + this.elements.controls = null; + this.elements.wrapper = null; + + // Callback + if (utils.is.function(callback)) { + callback(); + } + } else { + // Replace the container with the original element provided + const parent = this.elements.container.parentNode; + + if (utils.is.htmlElement(parent)) { + parent.replaceChild(this.elements.original, this.elements.container); + } + + // Event + utils.dispatchEvent.call(this, this.elements.original, 'destroyed', true); + + // Callback + if (utils.is.function(callback)) { + callback.call(this.elements.original); + } + + // Clear for GC + this.elements = null; + } + }; // Type specific stuff - switch (player.type) { + switch (this.type) { case 'youtube': // Clear timers - window.clearInterval(player.core.timers.buffering); - window.clearInterval(player.core.timers.playing); + window.clearInterval(this.timers.buffering); + window.clearInterval(this.timers.playing); // Destroy YouTube API - player.embed.destroy(); + this.embed.destroy(); // Clean up done(); @@ -5652,7 +1033,7 @@ case 'vimeo': // Destroy Vimeo API // then clean up (wait, to prevent postmessage errors) - player.embed.unload().then(done); + this.embed.unload().then(done); // Vimeo does not always return window.setTimeout(done, 200); @@ -5662,68 +1043,17 @@ case 'video': case 'audio': // Restore native video controls - player.core.toggleNativeControls(true); + ui.toggleNativeControls.call(this, true); // Clean up done(); break; - } - - function done() { - // Bail if already destroyed - if (player === null) { - return; - } - - // Default to restore original element - if (!utils.is.boolean(restore)) { - restore = true; - } - - // Reset overflow (incase destroyed while in fullscreen) - document.body.style.overflow = ''; - - // Replace the container with the original element provided - if (restore) { - var parent = player.elements.container.parentNode; - - if (utils.is.htmlElement(parent)) { - parent.replaceChild(player.elements.original, player.elements.container); - } - } - - // Event - player.core.trigger(player.elements.original, 'destroyed', true); - - // Callback - if (utils.is.function(callback)) { - callback.call(player.elements.original); - } - - // Allow chaining - player = null; - } - }; - - // Get the duration (or custom if set) - Plyr.prototype.getDuration = function() { - var player = this; - // It should be a number, but parse it just incase - var duration = parseInt(player.config.duration); - - // True duration - var mediaDuration = 0; - - // Only if duration available - if (player.media.duration !== null && !isNaN(player.media.duration)) { - mediaDuration = player.media.duration; + default: + break; } + } +} - // If custom duration is funky, use regular duration - return isNaN(duration) ? mediaDuration : duration; - }; - - return Plyr; -})); +export default Plyr; diff --git a/src/js/source.js b/src/js/source.js new file mode 100644 index 00000000..d2d5f61a --- /dev/null +++ b/src/js/source.js @@ -0,0 +1,162 @@ +// ========================================================================== +// Plyr source update +// ========================================================================== + +import types from './types'; +import utils from './utils'; +import media from './media'; +import ui from './ui'; +import support from './support'; + +const source = { + // Add elements to HTML5 media (source, tracks, etc) + insertElements(type, attributes) { + if (utils.is.string(attributes)) { + utils.insertElement(type, this.media, { + src: attributes, + }); + } else if (utils.is.array(attributes)) { + this.warn(attributes); + + attributes.forEach(attribute => { + utils.insertElement(type, this.media, attribute); + }); + } + }, + + // Update source + // Sources are not checked for support so be careful + change(input) { + if (!utils.is.object(input) || !('sources' in input) || !input.sources.length) { + this.warn('Invalid source format'); + return; + } + + // Cancel current network requests + media.cancelRequests.call(this); + + // Destroy instance and re-setup + this.destroy.call( + this, + () => { + // TODO: Reset menus here + + // Remove elements + utils.removeElement(this.media); + this.media = null; + + // Reset class name + if (utils.is.htmlElement(this.elements.container)) { + this.elements.container.removeAttribute('class'); + } + + // Set the type + if ('type' in input) { + this.type = input.type; + + // Get child type for video (it might be an embed) + if (this.type === 'video') { + const firstSource = input.sources[0]; + + if ('type' in firstSource && types.embed.includes(firstSource.type)) { + this.type = firstSource.type; + } + } + } + + // Check for support + this.supported = support.check(this.type, this.config.inline); + + // Create new markup + switch (this.type) { + case 'video': + this.media = utils.createElement('video'); + break; + + case 'audio': + this.media = utils.createElement('audio'); + break; + + case 'youtube': + case 'vimeo': + this.media = utils.createElement('div'); + this.embedId = input.sources[0].src; + break; + + default: + break; + } + + // Inject the new element + this.elements.container.appendChild(this.media); + + // Autoplay the new source? + if (utils.is.boolean(input.autoplay)) { + this.config.autoplay = input.autoplay; + } + + // Set attributes for audio and video + if (this.isHTML5) { + if (this.config.crossorigin) { + this.media.setAttribute('crossorigin', ''); + } + if (this.config.autoplay) { + this.media.setAttribute('autoplay', ''); + } + if ('poster' in input) { + this.media.setAttribute('poster', input.poster); + } + if (this.config.loop.active) { + this.media.setAttribute('loop', ''); + } + if (this.config.muted) { + this.media.setAttribute('muted', ''); + } + if (this.config.inline) { + this.media.setAttribute('playsinline', ''); + } + } + + // Restore class hooks + utils.toggleClass( + this.elements.container, + this.config.classNames.captions.active, + this.supported.ui && this.captions.enabled + ); + + ui.addStyleHook.call(this); + + // Set new sources for html5 + if (this.isHTML5) { + source.insertElements.call(this, 'source', input.sources); + } + + // Set video title + this.config.title = input.title; + + // Set up from scratch + media.setup.call(this); + + // HTML5 stuff + if (this.isHTML5) { + // Setup captions + if ('tracks' in input) { + source.insertElements.call(this, 'track', input.tracks); + } + + // Load HTML5 sources + this.media.load(); + } + + // If HTML5 or embed but not fully supported, setupInterface and call ready now + if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) { + // Setup interface + ui.build.call(this); + } + }, + true + ); + }, +}; + +export default source; diff --git a/src/js/storage.js b/src/js/storage.js new file mode 100644 index 00000000..0d6031be --- /dev/null +++ b/src/js/storage.js @@ -0,0 +1,56 @@ +// ========================================================================== +// Plyr storage +// ========================================================================== + +import support from './support'; +import utils from './utils'; + +// Save a value back to local storage +function set(value) { + // Bail if we don't have localStorage support or it's disabled + if (!support.storage || !this.config.storage.enabled) { + return; + } + + // Update the working copy of the values + utils.extend(this.storage, value); + + // Update storage + window.localStorage.setItem(this.config.storage.key, JSON.stringify(this.storage)); +} + +// Setup localStorage +function setup() { + let value = null; + let storage = {}; + + // Bail if we don't have localStorage support or it's disabled + if (!support.storage || !this.config.storage.enabled) { + return storage; + } + + // Clean up old volume + // https://github.com/sampotts/plyr/issues/171 + window.localStorage.removeItem('plyr-volume'); + + // load value from the current key + value = window.localStorage.getItem(this.config.storage.key); + + if (!value) { + // Key wasn't set (or had been cleared), move along + } else if (/^\d+(\.\d+)?$/.test(value)) { + // If value is a number, it's probably volume from an older + // version of this. See: https://github.com/sampotts/plyr/pull/313 + // Update the key to be JSON + set({ + volume: parseFloat(value), + }); + } else { + // Assume it's JSON from this or a later version of plyr + storage = JSON.parse(value); + } + + return storage; +} + +export default { setup, set }; diff --git a/src/js/support.js b/src/js/support.js new file mode 100644 index 00000000..78650c9f --- /dev/null +++ b/src/js/support.js @@ -0,0 +1,174 @@ +// ========================================================================== +// Plyr support checks +// ========================================================================== + +import utils from './utils'; + +// Check for feature support +const support = { + // Basic support + audio: 'canPlayType' in document.createElement('audio'), + video: 'canPlayType' in document.createElement('video'), + + // Check for support + // Basic functionality vs full UI + check(type, inline) { + let api = false; + let ui = false; + const browser = utils.getBrowser(); + const playsInline = browser.isIPhone && inline && support.inline; + + switch (type) { + case 'video': + api = support.video; + ui = api && support.rangeInput && (!browser.isIPhone || playsInline); + break; + + case 'audio': + api = support.audio; + ui = api && support.rangeInput; + break; + + case 'youtube': + api = true; + ui = support.rangeInput && (!browser.isIPhone || playsInline); + break; + + case 'vimeo': + api = true; + ui = support.rangeInput && !browser.isIPhone; + break; + + default: + api = support.audio && support.video; + ui = api && support.rangeInput; + } + + return { + api, + ui, + }; + }, + + // Local storage + // We can't assume if local storage is present that we can use it + storage: (() => { + if (!('localStorage' in window)) { + return false; + } + + // Try to use it (it might be disabled, e.g. user is in private/porn mode) + // see: https://github.com/sampotts/plyr/issues/131 + const test = '___test'; + try { + window.localStorage.setItem(test, test); + window.localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + })(), + + // Picture-in-picture support + // Safari only currently + pip: (() => { + const browser = utils.getBrowser(); + return !browser.isIPhone && utils.is.function(utils.createElement('video').webkitSetPresentationMode); + })(), + + // Airplay support + // Safari only currently + airplay: utils.is.function(window.WebKitPlaybackTargetAvailabilityEvent), + + // Inline playback support + // https://webkit.org/blog/6784/new-video-policies-for-ios/ + inline: 'playsInline' in document.createElement('video'), + + // Check for mime type support against a player instance + // Credits: http://diveintohtml5.info/everything.html + // Related: http://www.leanbackplayer.com/test/h5mt.html + mime(player, type) { + const media = { player }; + + try { + // Bail if no checking function + if (!utils.is.function(media.canPlayType)) { + return false; + } + + // Type specific checks + if (player.type === 'video') { + switch (type) { + case 'video/webm': + return media.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''); + case 'video/mp4': + return media.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''); + case 'video/ogg': + return media.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''); + default: + return false; + } + } else if (player.type === 'audio') { + switch (type) { + case 'audio/mpeg': + return media.canPlayType('audio/mpeg;').replace(/no/, ''); + case 'audio/ogg': + return media.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''); + case 'audio/wav': + return media.canPlayType('audio/wav; codecs="1"').replace(/no/, ''); + default: + return false; + } + } + } catch (e) { + return false; + } + + // If we got this far, we're stuffed + return false; + }, + + // Check for textTracks support + textTracks: 'textTracks' in document.createElement('video'), + + // Check for passive event listener support + // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md + // https://www.youtube.com/watch?v=NPM6172J22g + passiveListeners: (() => { + // Test via a getter in the options object to see if the passive property is accessed + let supported = false; + try { + const options = Object.defineProperty({}, 'passive', { + get() { + supported = true; + return null; + }, + }); + window.addEventListener('test', null, options); + } catch (e) { + // Do nothing + } + + return supported; + })(), + + // Sliders + rangeInput: (() => { + const range = document.createElement('input'); + range.type = 'range'; + return range.type === 'range'; + })(), + + // Touch + // Remember a device can be moust + touch enabled + touch: 'ontouchstart' in document.documentElement, + + // Detect transitions support + transitions: utils.transitionEnd !== false, + + // Reduced motion iOS & MacOS setting + // https://webkit.org/blog/7551/responsive-design-for-motion/ + reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches, +}; + +export default support; diff --git a/src/js/types.js b/src/js/types.js new file mode 100644 index 00000000..1f402a9b --- /dev/null +++ b/src/js/types.js @@ -0,0 +1,10 @@ +// ========================================================================== +// Plyr supported types +// ========================================================================== + +const types = { + embed: ['youtube', 'vimeo'], + html5: ['video', 'audio'], +}; + +export default types; diff --git a/src/js/ui.js b/src/js/ui.js new file mode 100644 index 00000000..2d612cdb --- /dev/null +++ b/src/js/ui.js @@ -0,0 +1,381 @@ +// ========================================================================== +// Plyr UI +// ========================================================================== + +import utils from './utils'; +import captions from './captions'; +import controls from './controls'; +import fullscreen from './fullscreen'; +import listeners from './listeners'; +import storage from './storage'; + +const ui = { + addStyleHook() { + utils.toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true); + utils.toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui); + }, + + // Toggle native HTML5 media controls + toggleNativeControls(toggle) { + if (toggle && this.isHTML5) { + this.media.setAttribute('controls', ''); + } else { + this.media.removeAttribute('controls'); + } + }, + + // Setup the UI + build() { + // Re-attach media element listeners + // TODO: Use event bubbling + listeners.media.call(this); + + // Don't setup interface if no support + if (!this.supported.ui) { + this.warn(`Basic support only for ${this.type}`); + + // Remove controls + utils.removeElement.call(this, 'controls'); + + // Remove large play + utils.removeElement.call(this, 'buttons.play'); + + // Restore native controls + ui.toggleNativeControls.call(this, true); + + // Bail + return; + } + + // Inject custom controls if not present + if (!utils.is.htmlElement(this.elements.controls)) { + // Inject custom controls + controls.inject.call(this); + + // Re-attach control listeners + listeners.controls.call(this); + } + + // If there's no controls, bail + if (!utils.is.htmlElement(this.elements.controls)) { + return; + } + + // Remove native controls + ui.toggleNativeControls.call(this); + + // Setup fullscreen + fullscreen.setup.call(this); + + // Captions + captions.setup.call(this); + + // Set volume + this.volume = null; + ui.updateVolume.call(this); + + // Set playback speed + this.speed = null; + + // Set loop + // this.setLoop(); + + // Reset time display + ui.timeUpdate.call(this); + + // Update the UI + ui.checkPlaying.call(this); + + this.ready = true; + + // Ready event at end of execution stack + utils.dispatchEvent.call(this, this.media, 'ready'); + + // Autoplay + if (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.media.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) + ui.updateSeekTooltip.call(this); + }, + + // Setup aria attribute for play and iframe title + setTitle() { + // Find the current text + let label = this.config.i18n.play; + + // If there's a media title set, use that for the label + if (utils.is.string(this.config.title) && !utils.is.empty(this.config.title)) { + label += `, ${this.config.title}`; + + // Set container label + this.elements.container.setAttribute('aria-label', this.config.title); + } + + // 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); + } + } + + // Set iframe title + // https://github.com/sampotts/plyr/issues/124 + if (this.isEmbed) { + const iframe = utils.getElement.call(this, 'iframe'); + + if (!utils.is.htmlElement(iframe)) { + return; + } + + // Default to media type + const title = !utils.is.empty(this.config.title) ? this.config.title : 'video'; + + iframe.setAttribute('title', this.config.i18n.frameTitle.replace('{title}', title)); + } + }, + + // Check playing state + checkPlaying() { + utils.toggleClass(this.elements.container, this.config.classNames.playing, !this.media.paused); + + utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.media.paused); + + this.toggleControls(this.media.paused); + }, + + // Update volume UI and storage + updateVolume() { + // Update the if present + if (this.supported.ui) { + const value = this.media.muted ? 0 : this.media.volume; + + if (this.elements.inputs.volume) { + ui.setRange.call(this, this.elements.inputs.volume, value); + } + } + + // Update the volume in storage + storage.set.call(this, { + volume: this.media.volume, + }); + + // Toggle class if muted + utils.toggleClass(this.elements.container, this.config.classNames.muted, this.media.muted); + + // Update checkbox for mute state + if (this.supported.ui && this.elements.buttons.mute) { + utils.toggleState(this.elements.buttons.mute, this.media.muted); + } + }, + + // Check if media is loading + checkLoading(event) { + this.loading = event.type === 'waiting'; + + // Clear timer + clearTimeout(this.timers.loading); + + // 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 seek value and lower fill + setRange(target, value) { + if (!utils.is.htmlElement(target)) { + return; + } + + target.value = value; + + // Webkit range fill + controls.updateRangeFill.call(this, target); + }, + + // Set 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; + + // Update value and label + if (utils.is.htmlElement(progress)) { + progress.value = value; + + // Update text label inside + const label = progress.getElementsByTagName('span')[0]; + if (utils.is.htmlElement(label)) { + label.childNodes[0].nodeValue = value; + } + } + }, + + // Update elements + updateProgress(event) { + if (!this.supported.ui) { + 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': + value = (() => { + const { buffered } = this.media; + + if (buffered && buffered.length) { + // HTML5 + return utils.getPercentage(buffered.end(0), this.duration); + } else if (utils.is.number(buffered)) { + // YouTube returns between 0 and 1 + return buffered * 100; + } + + return 0; + })(); + + ui.setProgress.call(this, this.elements.display.buffer, value); + + break; + + default: + break; + } + } + }, + + // Update the displayed time + updateTimeDisplay(value, element) { + // Bail if there's no duration display + if (!utils.is.htmlElement(element)) { + return null; + } + + // Fallback to 0 + const time = !Number.isNaN(value) ? value : 0; + + let secs = parseInt(time % 60, 10); + let mins = parseInt((time / 60) % 60, 10); + const hours = parseInt((time / 60 / 60) % 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); + + // Generate display + const display = `${(displayHours ? `${hours}:` : '') + mins}:${secs}`; + + // Render + element.textContent = display; + + // Return for looping + return display; + }, + + // Handle time change event + timeUpdate(event) { + // Duration + ui.updateTimeDisplay.call(this, this.currentTime, this.elements.display.currentTime); + + // Ignore updates while seeking + if (event && event.type === 'timeupdate' && this.media.seeking) { + return; + } + + // Playing progress + ui.updateProgress.call(this, event); + }, + + // Update hover tooltip for seeking + updateSeekTooltip(event) { + // Bail if setting not true + if ( + !this.config.tooltips.seek || + !utils.is.htmlElement(this.elements.inputs.seek) || + !utils.is.htmlElement(this.elements.display.seekTooltip) || + this.duration === 0 + ) { + return; + } + + // Calculate percentage + const clientRect = this.elements.inputs.seek.getBoundingClientRect(); + let percent = 0; + const visible = `${this.config.classNames.tooltip}--visible`; + + // Determine percentage, if already visible + if (utils.is.event(event)) { + percent = 100 / clientRect.width * (event.pageX - clientRect.left); + } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) { + percent = this.elements.display.seekTooltip.style.left.replace('%', ''); + } else { + return; + } + + // Set bounds + if (percent < 0) { + percent = 0; + } else if (percent > 100) { + percent = 100; + } + + // Display the time a click would seek to + ui.updateTimeDisplay.call(this, this.duration / 100 * percent, this.elements.display.seekTooltip); + + // Set position + this.elements.display.seekTooltip.style.left = `${percent}%`; + + // Show/hide the tooltip + // If the event is a moues in/out and percentage is inside bounds + if (utils.is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { + utils.toggleClass(this.elements.display.seekTooltip, visible, event.type === 'mouseenter'); + } + }, +}; + +export default ui; diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 00000000..e81954f4 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,667 @@ +// ========================================================================== +// Plyr utils +// ========================================================================== + +import support from './support'; + +const utils = { + // Check variable types + is: { + object(input) { + return this.getConstructor(input) === Object; + }, + number(input) { + return this.getConstructor(input) === Number && !Number.isNaN(input); + }, + string(input) { + return this.getConstructor(input) === String; + }, + boolean(input) { + return this.getConstructor(input) === Boolean; + }, + function(input) { + return this.getConstructor(input) === Function; + }, + array(input) { + return !this.undefined(input) && Array.isArray(input); + }, + nodeList(input) { + return !this.undefined(input) && input instanceof NodeList; + }, + htmlElement(input) { + return !this.undefined(input) && input instanceof HTMLElement; + }, + event(input) { + return !this.undefined(input) && input instanceof Event; + }, + cue(input) { + return this.instanceOf(input, window.TextTrackCue) || this.instanceOf(input, window.VTTCue); + }, + track(input) { + return ( + !this.undefined(input) && (this.instanceOf(input, window.TextTrack) || typeof input.kind === 'string') + ); + }, + undefined(input) { + return input !== null && typeof input === 'undefined'; + }, + empty(input) { + 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) + ); + }, + getConstructor(input) { + if (input === null || typeof input === 'undefined') { + return null; + } + + return input.constructor; + }, + instanceOf(input, constructor) { + return Boolean(input && constructor && input instanceof constructor); + }, + }, + + // Unfortunately, due to mixed support, UA sniffing is required + getBrowser() { + return { + isIE: /* @cc_on!@ */ false || !!document.documentMode, + isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent), + isIPhone: /(iPhone|iPod)/gi.test(navigator.platform), + isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform), + }; + }, + + // Load an external script + loadScript(url) { + // Check script is not already referenced + if (document.querySelectorAll(`script[src="${url}"]`).length) { + return; + } + + const tag = document.createElement('script'); + tag.src = url; + + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + }, + + // Generate a random ID + generateId(prefix) { + return `${prefix}-${Math.floor(Math.random() * 10000)}`; + }, + + // Determine if we're in an iframe + inFrame() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } + }, + + // Wrap an element + wrap(elements, wrapper) { + // Convert `elements` to an array, if necessary. + const targets = elements.length ? elements : [elements]; + + // Loops backwards to prevent having to clone the wrapper on the + // first element (see `child` below). + Array.from(targets) + .reverse() + .forEach((element, index) => { + const child = index > 0 ? wrapper.cloneNode(true) : wrapper; + + // Cache the current parent and sibling. + const parent = element.parentNode; + const sibling = element.nextSibling; + + // Wrap the element (is automatically removed from its current + // parent). + child.appendChild(element); + + // If the element had a sibling, insert the wrapper before + // the sibling to maintain the HTML structure; otherwise, just + // append it to the parent. + if (sibling) { + parent.insertBefore(child, sibling); + } else { + parent.appendChild(child); + } + }); + }, + + // 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 + const element = document.createElement(type); + + // Set all passed attributes + if (utils.is.object(attributes)) { + utils.setAttributes(element, attributes); + } + + // Add text node + if (utils.is.string(text)) { + element.textContent = text; + } + + // Return built element + return element; + }, + + // Insert a DocumentFragment + insertElement(type, parent, attributes, text) { + // Inject the new + parent.appendChild(utils.createElement(type, attributes, text)); + }, + + // Remove all child elements + emptyElement(element) { + let { length } = element.childNodes; + + while (length > 0) { + element.removeChild(element.lastChild); + length -= 1; + } + }, + + // Set attributes + setAttributes(element, attributes) { + Object.keys(attributes).forEach(key => { + element.setAttribute(key, attributes[key]); + }); + }, + + // Get an attribute object from a string selector + getAttributesFromSelector(sel, existingAttributes) { + // For example: + // '.test' to { class: 'test' } + // '#test' to { id: 'test' } + // '[data-test="test"]' to { 'data-test': 'test' } + + if (!utils.is.string(sel) || utils.is.empty(sel)) { + return {}; + } + + const attributes = {}; + const existing = existingAttributes; + + sel.split(',').forEach(s => { + // Remove whitespace + const selector = s.trim(); + const className = selector.replace('.', ''); + const stripped = selector.replace(/[[\]]/g, ''); + + // Get the parts and value + const parts = stripped.split('='); + const key = parts[0]; + const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; + + // Get the first character + const start = selector.charAt(0); + + switch (start) { + case '.': + // Add to existing classname + if (utils.is.object(existing) && utils.is.string(existing.class)) { + existing.class += ` ${className}`; + } + + attributes.class = className; + break; + + case '#': + // ID selector + attributes.id = selector.replace('#', ''); + break; + + case '[': + // Attribute selector + attributes[key] = value; + + break; + + default: + break; + } + }); + + return attributes; + }, + + // Toggle class on an element + toggleClass(element, className, toggle) { + if (utils.is.htmlElement(element)) { + const contains = element.classList.contains(className); + + element.classList[toggle ? 'add' : 'remove'](className); + + return (toggle && !contains) || (!toggle && contains); + } + + return null; + }, + + // Has class name + hasClass(element, className) { + return utils.is.htmlElement(element) && element.classList.contains(className); + }, + + // Element matches selector + matches(element, selector) { + const prototype = { Element }; + + function match() { + return Array.from(document.querySelectorAll(selector)).includes(this); + } + + const matches = + prototype.matches || + prototype.webkitMatchesSelector || + prototype.mozMatchesSelector || + prototype.msMatchesSelector || + match; + + return matches.call(element, selector); + }, + + // Find all elements + getElements(selector) { + return this.elements.container.querySelectorAll(selector); + }, + + // Find a single element + getElement(selector) { + return this.elements.container.querySelector(selector); + }, + + // Find the UI controls and store references in custom controls + // TODO: Allow settings menus with custom controls + findElements() { + try { + this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper); + + // Buttons + this.elements.buttons = { + play: utils.getElements.call(this, this.config.selectors.buttons.play), + pause: utils.getElement.call(this, this.config.selectors.buttons.pause), + restart: utils.getElement.call(this, this.config.selectors.buttons.restart), + rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind), + forward: utils.getElement.call(this, this.config.selectors.buttons.forward), + mute: utils.getElement.call(this, this.config.selectors.buttons.mute), + pip: utils.getElement.call(this, this.config.selectors.buttons.pip), + airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay), + settings: utils.getElement.call(this, this.config.selectors.buttons.settings), + captions: utils.getElement.call(this, this.config.selectors.buttons.captions), + fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen), + }; + + // Progress + this.elements.progress = utils.getElement.call(this, this.config.selectors.progress); + + // Inputs + this.elements.inputs = { + seek: utils.getElement.call(this, this.config.selectors.inputs.seek), + volume: utils.getElement.call(this, this.config.selectors.inputs.volume), + }; + + // Display + this.elements.display = { + buffer: utils.getElement.call(this, this.config.selectors.display.buffer), + duration: utils.getElement.call(this, this.config.selectors.display.duration), + currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime), + }; + + // Seek tooltip + if (utils.is.htmlElement(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector( + `.${this.config.classNames.tooltip}` + ); + } + + return true; + } catch (error) { + // Log it + this.warn('It looks like there is a problem with your custom controls HTML', error); + + // Restore native video controls + this.toggleNativeControls(true); + + return false; + } + }, + + // Get the focused element + getFocusElement() { + let focused = document.activeElement; + + if (!focused || focused === document.body) { + focused = null; + } else { + focused = document.querySelector(':focus'); + } + + return focused; + }, + + // Trap focus inside container + trapFocus() { + const tabbables = utils.getElements.call(this, 'input:not([disabled]), button:not([disabled])'); + const first = tabbables[0]; + const last = tabbables[tabbables.length - 1]; + + utils.on( + this.elements.container, + 'keydown', + event => { + // If it is tab + if (event.which === 9 && this.fullscreen.active) { + if (event.target === last && !event.shiftKey) { + // Move focus to first element that can be tabbed if Shift isn't used + event.preventDefault(); + first.focus(); + } else if (event.target === first && event.shiftKey) { + // Move focus to last element that can be tabbed if Shift is used + event.preventDefault(); + last.focus(); + } + } + }, + false + ); + }, + + // Bind along with custom handler + proxy(element, eventName, customListener, defaultListener, passive, capture) { + utils.on( + element, + eventName, + event => { + if (customListener) { + customListener.apply(element, [event]); + } + defaultListener.apply(element, [event]); + }, + passive, + capture + ); + }, + + // Toggle event listener + toggleListener(elements, event, callback, toggle, passive, capture) { + // Bail if no elements + if (elements === null || utils.is.undefined(elements)) { + return; + } + + // If a nodelist is passed, call itself on each node + if (elements instanceof NodeList) { + // Create listener for each node + Array.from(elements).forEach(element => { + if (element instanceof Node) { + utils.toggleListener.call(null, element, event, callback, toggle, passive, capture); + } + }); + + return; + } + + // Allow multiple events + const events = event.split(' '); + + // Build options + // Default to just capture boolean + let options = utils.is.boolean(capture) ? capture : false; + + // If passive events listeners are supported + if (support.passiveListeners) { + options = { + // Whether the listener can be passive (i.e. default never prevented) + passive: utils.is.boolean(passive) ? passive : true, + // Whether the listener is a capturing listener or not + capture: utils.is.boolean(capture) ? capture : false, + }; + } + + // If a single node is passed, bind the event listener + events.forEach(type => { + elements[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); + }, + + // Bind event handler + on(element, events, callback, passive, capture) { + utils.toggleListener(element, events, callback, true, passive, capture); + }, + + // Unbind event handler + off(element, events, callback, passive, capture) { + utils.toggleListener(element, events, callback, false, passive, capture); + }, + + // Trigger event + dispatchEvent(element, type, bubbles, properties) { + // Bail if no element + if (!element || !type) { + return; + } + + // Create and dispatch the event + const event = new CustomEvent(type, { + bubbles: utils.is.boolean(bubbles) ? bubbles : false, + detail: Object.assign({}, properties, { + plyr: this instanceof Plyr ? this : null, + }), + }); + + // Dispatch the event + element.dispatchEvent(event); + }, + + // Toggle aria-pressed state on a toggle button + // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles + toggleState(target, state) { + // Bail if no target + if (!target) { + return null; + } + + // Get state + const newState = utils.is.boolean(state) ? state : !target.getAttribute('aria-pressed'); + + // Set the attribute on target + target.setAttribute('aria-pressed', newState); + + return newState; + }, + + // Get percentage + getPercentage(current, max) { + if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { + return 0; + } + return (current / max * 100).toFixed(2); + }, + + // Deep extend/merge destination object with N more objects + // http://andrewdupont.net/2009/08/28/deep-extending-objects-in-javascript/ + // Removed call to arguments.callee (used explicit function name instead) + extend(...objects) { + const { length } = objects; + + // Bail if nothing to merge + if (!length) { + return null; + } + + // Return first if specified but nothing to merge + if (length === 1) { + return objects[0]; + } + + // First object is the destination + let destination = Array.prototype.shift.call(objects); + if (!utils.is.object(destination)) { + destination = {}; + } + + // Loop through all objects to merge + objects.forEach(source => { + if (!utils.is.object(source)) { + return; + } + + Object.keys(source).forEach(property => { + if (source[property] && source[property].constructor && source[property].constructor === Object) { + destination[property] = destination[property] || {}; + utils.extend(destination[property], source[property]); + } else { + destination[property] = source[property]; + } + }); + }); + + return destination; + }, + + // Parse YouTube ID from URL + parseYouTubeId(url) { + const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + return url.match(regex) ? RegExp.$2 : url; + }, + + // Parse Vimeo ID from URL + parseVimeoId(url) { + if (utils.is.number(Number(url))) { + return url; + } + + const regex = /^.*(vimeo.com\/|video\/)(\d+).*/; + return url.match(regex) ? RegExp.$2 : url; + }, + + // Convert object to URL parameters + buildUrlParameters(input) { + if (!utils.is.object(input)) { + return ''; + } + + return Object.keys(input) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(input[key])}`) + .join('&'); + }, + + // Remove HTML from a string + stripHTML(source) { + const fragment = document.createDocumentFragment(); + const element = document.createElement('div'); + fragment.appendChild(element); + element.innerHTML = source; + return fragment.firstChild.innerText; + }, + + // Load an SVG sprite + loadSprite(url, id) { + if (typeof url !== 'string') { + return; + } + + const prefix = 'cache-'; + const hasId = typeof id === 'string'; + let isCached = false; + + function updateSprite(data) { + // Inject content + this.innerHTML = data; + + // Inject the SVG to the body + document.body.insertBefore(this, document.body.childNodes[0]); + } + + // Only load once + if (!hasId || !document.querySelectorAll(`#${id}`).length) { + // Create container + const container = document.createElement('div'); + container.setAttribute('hidden', ''); + + if (hasId) { + container.setAttribute('id', id); + } + + // Check in cache + if (support.storage) { + const cached = window.localStorage.getItem(prefix + id); + isCached = cached !== null; + + if (isCached) { + const data = JSON.parse(cached); + updateSprite.call(container, data.content); + } + } + + // 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, + }) + ); + } + + updateSprite.call(container, xhr.responseText); + }; + + xhr.send(); + } + }, + + // Get the transition end event + transitionEnd: (() => { + const element = document.createElement('span'); + + const events = { + WebkitTransition: 'webkitTransitionEnd', + MozTransition: 'transitionend', + OTransition: 'oTransitionEnd otransitionend', + transition: 'transitionend', + }; + + const type = Object.keys(events).find(event => element.style[event] !== undefined); + + return typeof type === 'string' ? type : false; + })(), +}; + +export default utils; diff --git a/src/less/base.less b/src/less/base.less index e41874a0..c6272e11 100644 --- a/src/less/base.less +++ b/src/less/base.less @@ -7,7 +7,6 @@ position: relative; max-width: 100%; min-width: 200px; - overflow: hidden; font-family: @plyr-font-family; font-weight: @plyr-font-weight-normal; direction: ltr; diff --git a/src/less/components/controls.less b/src/less/components/controls.less index 2750de32..dc4ea8b3 100644 --- a/src/less/components/controls.less +++ b/src/less/components/controls.less @@ -58,7 +58,7 @@ border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; color: @plyr-video-control-color; - transition: all 0.4s ease-in-out; + transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out; .plyr__control { svg { diff --git a/src/less/components/embed.less b/src/less/components/embed.less index 02b33425..f6b5b307 100644 --- a/src/less/components/embed.less +++ b/src/less/components/embed.less @@ -16,6 +16,13 @@ border: 0; user-select: none; } + + // Vimeo hack + > div { + position: relative; + padding-bottom: 200%; + transform: translateY(-35.95%); + } } // To allow mouse events to be captured if full support .plyr--full-ui .plyr__video-embed iframe { diff --git a/src/less/components/sliders.less b/src/less/components/sliders.less index e2a5ffcf..b3b328e3 100644 --- a/src/less/components/sliders.less +++ b/src/less/components/sliders.less @@ -46,10 +46,8 @@ // Microsoft &::-ms-track { - height: @plyr-range-track-height; - background: transparent; - border: 0; color: transparent; + .plyr-range-track(); } &::-ms-fill-upper { diff --git a/src/less/components/video.less b/src/less/components/video.less index b68bac96..f5ff01c8 100644 --- a/src/less/components/video.less +++ b/src/less/components/video.less @@ -2,6 +2,10 @@ // Video styles // -------------------------------------------------------------- +.plyr--video { + overflow: hidden; +} + .plyr__video-wrapper { position: relative; background: #000; -- cgit v1.2.3