From d283f7e661e14d6ae1881fe803e5b4f1ed0689ff Mon Sep 17 00:00:00 2001 From: David Thompson Date: Mon, 24 Jun 2024 13:49:08 -0400 Subject: Add 2024 Guix social talk. --- .../reveal.js/js/controllers/slidecontent.js | 489 +++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 2024-06-18-guix-social/reveal.js/js/controllers/slidecontent.js (limited to '2024-06-18-guix-social/reveal.js/js/controllers/slidecontent.js') diff --git a/2024-06-18-guix-social/reveal.js/js/controllers/slidecontent.js b/2024-06-18-guix-social/reveal.js/js/controllers/slidecontent.js new file mode 100644 index 0000000..2a24325 --- /dev/null +++ b/2024-06-18-guix-social/reveal.js/js/controllers/slidecontent.js @@ -0,0 +1,489 @@ +import { extend, queryAll, closest, getMimeTypeFromFile, encodeRFC3986URI } from '../utils/util.js' +import { isMobile } from '../utils/device.js' + +import fitty from 'fitty'; + +/** + * Handles loading, unloading and playback of slide + * content such as images, videos and iframes. + */ +export default class SlideContent { + + constructor( Reveal ) { + + this.Reveal = Reveal; + + this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this ); + + } + + /** + * Should the given element be preloaded? + * Decides based on local element attributes and global config. + * + * @param {HTMLElement} element + */ + shouldPreload( element ) { + + if( this.Reveal.isScrollView() ) { + return true; + } + + // Prefer an explicit global preload setting + let preload = this.Reveal.getConfig().preloadIframes; + + // If no global setting is available, fall back on the element's + // own preload setting + if( typeof preload !== 'boolean' ) { + preload = element.hasAttribute( 'data-preload' ); + } + + return preload; + } + + /** + * Called when the given slide is within the configured view + * distance. Shows the slide element and loads any content + * that is set to load lazily (data-src). + * + * @param {HTMLElement} slide Slide to show + */ + load( slide, options = {} ) { + + // Show the slide element + slide.style.display = this.Reveal.getConfig().display; + + // Media elements with data-src attributes + queryAll( slide, 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ).forEach( element => { + if( element.tagName !== 'IFRAME' || this.shouldPreload( element ) ) { + element.setAttribute( 'src', element.getAttribute( 'data-src' ) ); + element.setAttribute( 'data-lazy-loaded', '' ); + element.removeAttribute( 'data-src' ); + } + } ); + + // Media elements with children + queryAll( slide, 'video, audio' ).forEach( media => { + let sources = 0; + + queryAll( media, 'source[data-src]' ).forEach( source => { + source.setAttribute( 'src', source.getAttribute( 'data-src' ) ); + source.removeAttribute( 'data-src' ); + source.setAttribute( 'data-lazy-loaded', '' ); + sources += 1; + } ); + + // Enable inline video playback in mobile Safari + if( isMobile && media.tagName === 'VIDEO' ) { + media.setAttribute( 'playsinline', '' ); + } + + // If we rewrote sources for this video/audio element, we need + // to manually tell it to load from its new origin + if( sources > 0 ) { + media.load(); + } + } ); + + + // Show the corresponding background element + let background = slide.slideBackgroundElement; + if( background ) { + background.style.display = 'block'; + + let backgroundContent = slide.slideBackgroundContentElement; + let backgroundIframe = slide.getAttribute( 'data-background-iframe' ); + + // If the background contains media, load it + if( background.hasAttribute( 'data-loaded' ) === false ) { + background.setAttribute( 'data-loaded', 'true' ); + + let backgroundImage = slide.getAttribute( 'data-background-image' ), + backgroundVideo = slide.getAttribute( 'data-background-video' ), + backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ), + backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' ); + + // Images + if( backgroundImage ) { + // base64 + if( /^data:/.test( backgroundImage.trim() ) ) { + backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`; + } + // URL(s) + else { + backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => { + // Decode URL(s) that are already encoded first + let decoded = decodeURI(background.trim()); + return `url(${encodeRFC3986URI(decoded)})`; + }).join( ',' ); + } + } + // Videos + else if ( backgroundVideo && !this.Reveal.isSpeakerNotes() ) { + let video = document.createElement( 'video' ); + + if( backgroundVideoLoop ) { + video.setAttribute( 'loop', '' ); + } + + if( backgroundVideoMuted ) { + video.muted = true; + } + + // Enable inline playback in mobile Safari + // + // Mute is required for video to play when using + // swipe gestures to navigate since they don't + // count as direct user actions :'( + if( isMobile ) { + video.muted = true; + video.setAttribute( 'playsinline', '' ); + } + + // Support comma separated lists of video sources + backgroundVideo.split( ',' ).forEach( source => { + const sourceElement = document.createElement( 'source' ); + sourceElement.setAttribute( 'src', source ); + + let type = getMimeTypeFromFile( source ); + if( type ) { + sourceElement.setAttribute( 'type', type ); + } + + video.appendChild( sourceElement ); + } ); + + backgroundContent.appendChild( video ); + } + // Iframes + else if( backgroundIframe && options.excludeIframes !== true ) { + let iframe = document.createElement( 'iframe' ); + iframe.setAttribute( 'allowfullscreen', '' ); + iframe.setAttribute( 'mozallowfullscreen', '' ); + iframe.setAttribute( 'webkitallowfullscreen', '' ); + iframe.setAttribute( 'allow', 'autoplay' ); + + iframe.setAttribute( 'data-src', backgroundIframe ); + + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.maxHeight = '100%'; + iframe.style.maxWidth = '100%'; + + backgroundContent.appendChild( iframe ); + } + } + + // Start loading preloadable iframes + let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' ); + if( backgroundIframeElement ) { + + // Check if this iframe is eligible to be preloaded + if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) { + if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) { + backgroundIframeElement.setAttribute( 'src', backgroundIframe ); + } + } + + } + + } + + this.layout( slide ); + + } + + /** + * Applies JS-dependent layout helpers for the scope. + */ + layout( scopeElement ) { + + // Autosize text with the r-fit-text class based on the + // size of its container. This needs to happen after the + // slide is visible in order to measure the text. + Array.from( scopeElement.querySelectorAll( '.r-fit-text' ) ).forEach( element => { + fitty( element, { + minSize: 24, + maxSize: this.Reveal.getConfig().height * 0.8, + observeMutations: false, + observeWindow: false + } ); + } ); + + } + + /** + * Unloads and hides the given slide. This is called when the + * slide is moved outside of the configured view distance. + * + * @param {HTMLElement} slide + */ + unload( slide ) { + + // Hide the slide element + slide.style.display = 'none'; + + // Hide the corresponding background element + let background = this.Reveal.getSlideBackground( slide ); + if( background ) { + background.style.display = 'none'; + + // Unload any background iframes + queryAll( background, 'iframe[src]' ).forEach( element => { + element.removeAttribute( 'src' ); + } ); + } + + // Reset lazy-loaded media elements with src attributes + queryAll( slide, 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ).forEach( element => { + element.setAttribute( 'data-src', element.getAttribute( 'src' ) ); + element.removeAttribute( 'src' ); + } ); + + // Reset lazy-loaded media elements with children + queryAll( slide, 'video[data-lazy-loaded] source[src], audio source[src]' ).forEach( source => { + source.setAttribute( 'data-src', source.getAttribute( 'src' ) ); + source.removeAttribute( 'src' ); + } ); + + } + + /** + * Enforces origin-specific format rules for embedded media. + */ + formatEmbeddedContent() { + + let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => { + queryAll( this.Reveal.getSlidesElement(), 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ).forEach( el => { + let src = el.getAttribute( sourceAttribute ); + if( src && src.indexOf( param ) === -1 ) { + el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param ); + } + }); + }; + + // YouTube frames must include "?enablejsapi=1" + _appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' ); + _appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' ); + + // Vimeo frames must include "?api=1" + _appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' ); + _appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' ); + + } + + /** + * Start playback of any embedded content inside of + * the given element. + * + * @param {HTMLElement} element + */ + startEmbeddedContent( element ) { + + if( element && !this.Reveal.isSpeakerNotes() ) { + + // Restart GIFs + queryAll( element, 'img[src$=".gif"]' ).forEach( el => { + // Setting the same unchanged source like this was confirmed + // to work in Chrome, FF & Safari + el.setAttribute( 'src', el.getAttribute( 'src' ) ); + } ); + + // HTML5 media elements + queryAll( element, 'video, audio' ).forEach( el => { + if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) { + return; + } + + // Prefer an explicit global autoplay setting + let autoplay = this.Reveal.getConfig().autoPlayMedia; + + // If no global setting is available, fall back on the element's + // own autoplay setting + if( typeof autoplay !== 'boolean' ) { + autoplay = el.hasAttribute( 'data-autoplay' ) || !!closest( el, '.slide-background' ); + } + + if( autoplay && typeof el.play === 'function' ) { + + // If the media is ready, start playback + if( el.readyState > 1 ) { + this.startEmbeddedMedia( { target: el } ); + } + // Mobile devices never fire a loaded event so instead + // of waiting, we initiate playback + else if( isMobile ) { + let promise = el.play(); + + // If autoplay does not work, ensure that the controls are visible so + // that the viewer can start the media on their own + if( promise && typeof promise.catch === 'function' && el.controls === false ) { + promise.catch( () => { + el.controls = true; + + // Once the video does start playing, hide the controls again + el.addEventListener( 'play', () => { + el.controls = false; + } ); + } ); + } + } + // If the media isn't loaded, wait before playing + else { + el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes + el.addEventListener( 'loadeddata', this.startEmbeddedMedia ); + } + + } + } ); + + // Normal iframes + queryAll( element, 'iframe[src]' ).forEach( el => { + if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) { + return; + } + + this.startEmbeddedIframe( { target: el } ); + } ); + + // Lazy loading iframes + queryAll( element, 'iframe[data-src]' ).forEach( el => { + if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) { + return; + } + + if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) { + el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes + el.addEventListener( 'load', this.startEmbeddedIframe ); + el.setAttribute( 'src', el.getAttribute( 'data-src' ) ); + } + } ); + + } + + } + + /** + * Starts playing an embedded video/audio element after + * it has finished loading. + * + * @param {object} event + */ + startEmbeddedMedia( event ) { + + let isAttachedToDOM = !!closest( event.target, 'html' ), + isVisible = !!closest( event.target, '.present' ); + + if( isAttachedToDOM && isVisible ) { + // Don't restart if media is already playing + if( event.target.paused || event.target.ended ) { + event.target.currentTime = 0; + event.target.play(); + } + } + + event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); + + } + + /** + * "Starts" the content of an embedded iframe using the + * postMessage API. + * + * @param {object} event + */ + startEmbeddedIframe( event ) { + + let iframe = event.target; + + if( iframe && iframe.contentWindow ) { + + let isAttachedToDOM = !!closest( event.target, 'html' ), + isVisible = !!closest( event.target, '.present' ); + + if( isAttachedToDOM && isVisible ) { + + // Prefer an explicit global autoplay setting + let autoplay = this.Reveal.getConfig().autoPlayMedia; + + // If no global setting is available, fall back on the element's + // own autoplay setting + if( typeof autoplay !== 'boolean' ) { + autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closest( iframe, '.slide-background' ); + } + + // YouTube postMessage API + if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) { + iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' ); + } + // Vimeo postMessage API + else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) { + iframe.contentWindow.postMessage( '{"method":"play"}', '*' ); + } + // Generic postMessage API + else { + iframe.contentWindow.postMessage( 'slide:start', '*' ); + } + + } + + } + + } + + /** + * Stop playback of any embedded content inside of + * the targeted slide. + * + * @param {HTMLElement} element + */ + stopEmbeddedContent( element, options = {} ) { + + options = extend( { + // Defaults + unloadIframes: true + }, options ); + + if( element && element.parentNode ) { + // HTML5 media elements + queryAll( element, 'video, audio' ).forEach( el => { + if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) { + el.setAttribute('data-paused-by-reveal', ''); + el.pause(); + } + } ); + + // Generic postMessage API for non-lazy loaded iframes + queryAll( element, 'iframe' ).forEach( el => { + if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' ); + el.removeEventListener( 'load', this.startEmbeddedIframe ); + }); + + // YouTube postMessage API + queryAll( element, 'iframe[src*="youtube.com/embed/"]' ).forEach( el => { + if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) { + el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' ); + } + }); + + // Vimeo postMessage API + queryAll( element, 'iframe[src*="player.vimeo.com/"]' ).forEach( el => { + if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) { + el.contentWindow.postMessage( '{"method":"pause"}', '*' ); + } + }); + + if( options.unloadIframes === true ) { + // Unload lazy-loaded iframes + queryAll( element, 'iframe[data-src]' ).forEach( el => { + // Only removing the src doesn't actually unload the frame + // in all browsers (Firefox) so we set it to blank first + el.setAttribute( 'src', 'about:blank' ); + el.removeAttribute( 'src' ); + } ); + } + } + + } + +} -- cgit v1.2.3