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) { // 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) => { let type = getMimeTypeFromFile(source); if (type) { video.innerHTML += ``; } else { video.innerHTML += ``; } }); 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) { 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"); }); } } } }