blob: 5462dbfe44fb7625479d7afdc9050d64686aea9d [file] [log] [blame]
Marc Kupietz09b75752023-10-07 09:32:19 +02001import { extend, queryAll, closest, getMimeTypeFromFile, encodeRFC3986URI } from '../utils/util.js'
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +02002import { isMobile } from '../utils/device.js'
3
4import fitty from 'fitty';
5
6/**
7 * Handles loading, unloading and playback of slide
8 * content such as images, videos and iframes.
9 */
10export default class SlideContent {
11
12 constructor( Reveal ) {
13
14 this.Reveal = Reveal;
15
16 this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this );
17
18 }
19
20 /**
21 * Should the given element be preloaded?
22 * Decides based on local element attributes and global config.
23 *
24 * @param {HTMLElement} element
25 */
26 shouldPreload( element ) {
27
28 // Prefer an explicit global preload setting
29 let preload = this.Reveal.getConfig().preloadIframes;
30
31 // If no global setting is available, fall back on the element's
32 // own preload setting
33 if( typeof preload !== 'boolean' ) {
34 preload = element.hasAttribute( 'data-preload' );
35 }
36
37 return preload;
38 }
39
40 /**
41 * Called when the given slide is within the configured view
42 * distance. Shows the slide element and loads any content
43 * that is set to load lazily (data-src).
44 *
45 * @param {HTMLElement} slide Slide to show
46 */
47 load( slide, options = {} ) {
48
49 // Show the slide element
50 slide.style.display = this.Reveal.getConfig().display;
51
52 // Media elements with data-src attributes
53 queryAll( slide, 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ).forEach( element => {
54 if( element.tagName !== 'IFRAME' || this.shouldPreload( element ) ) {
55 element.setAttribute( 'src', element.getAttribute( 'data-src' ) );
56 element.setAttribute( 'data-lazy-loaded', '' );
57 element.removeAttribute( 'data-src' );
58 }
59 } );
60
61 // Media elements with <source> children
62 queryAll( slide, 'video, audio' ).forEach( media => {
63 let sources = 0;
64
65 queryAll( media, 'source[data-src]' ).forEach( source => {
66 source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
67 source.removeAttribute( 'data-src' );
68 source.setAttribute( 'data-lazy-loaded', '' );
69 sources += 1;
70 } );
71
72 // Enable inline video playback in mobile Safari
73 if( isMobile && media.tagName === 'VIDEO' ) {
74 media.setAttribute( 'playsinline', '' );
75 }
76
77 // If we rewrote sources for this video/audio element, we need
78 // to manually tell it to load from its new origin
79 if( sources > 0 ) {
80 media.load();
81 }
82 } );
83
84
85 // Show the corresponding background element
86 let background = slide.slideBackgroundElement;
87 if( background ) {
88 background.style.display = 'block';
89
90 let backgroundContent = slide.slideBackgroundContentElement;
91 let backgroundIframe = slide.getAttribute( 'data-background-iframe' );
92
93 // If the background contains media, load it
94 if( background.hasAttribute( 'data-loaded' ) === false ) {
95 background.setAttribute( 'data-loaded', 'true' );
96
97 let backgroundImage = slide.getAttribute( 'data-background-image' ),
98 backgroundVideo = slide.getAttribute( 'data-background-video' ),
99 backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ),
100 backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' );
101
102 // Images
103 if( backgroundImage ) {
Christophe Dervieux8afae132021-12-06 15:16:42 +0100104 // base64
105 if( /^data:/.test( backgroundImage.trim() ) ) {
106 backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`;
107 }
108 // URL(s)
109 else {
110 backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => {
Marc Kupietz09b75752023-10-07 09:32:19 +0200111 // Decode URL(s) that are already encoded first
112 let decoded = decodeURI(background.trim());
113 return `url(${encodeRFC3986URI(decoded)})`;
Christophe Dervieux8afae132021-12-06 15:16:42 +0100114 }).join( ',' );
115 }
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +0200116 }
117 // Videos
118 else if ( backgroundVideo && !this.Reveal.isSpeakerNotes() ) {
119 let video = document.createElement( 'video' );
120
121 if( backgroundVideoLoop ) {
122 video.setAttribute( 'loop', '' );
123 }
124
125 if( backgroundVideoMuted ) {
126 video.muted = true;
127 }
128
129 // Enable inline playback in mobile Safari
130 //
131 // Mute is required for video to play when using
132 // swipe gestures to navigate since they don't
133 // count as direct user actions :'(
134 if( isMobile ) {
135 video.muted = true;
136 video.setAttribute( 'playsinline', '' );
137 }
138
139 // Support comma separated lists of video sources
140 backgroundVideo.split( ',' ).forEach( source => {
Christophe Dervieux8afae132021-12-06 15:16:42 +0100141 let type = getMimeTypeFromFile( source );
142 if( type ) {
143 video.innerHTML += `<source src="${source}" type="${type}">`;
144 }
145 else {
146 video.innerHTML += `<source src="${source}">`;
147 }
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +0200148 } );
149
150 backgroundContent.appendChild( video );
151 }
152 // Iframes
153 else if( backgroundIframe && options.excludeIframes !== true ) {
154 let iframe = document.createElement( 'iframe' );
155 iframe.setAttribute( 'allowfullscreen', '' );
156 iframe.setAttribute( 'mozallowfullscreen', '' );
157 iframe.setAttribute( 'webkitallowfullscreen', '' );
158 iframe.setAttribute( 'allow', 'autoplay' );
159
160 iframe.setAttribute( 'data-src', backgroundIframe );
161
162 iframe.style.width = '100%';
163 iframe.style.height = '100%';
164 iframe.style.maxHeight = '100%';
165 iframe.style.maxWidth = '100%';
166
167 backgroundContent.appendChild( iframe );
168 }
169 }
170
171 // Start loading preloadable iframes
172 let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' );
173 if( backgroundIframeElement ) {
174
175 // Check if this iframe is eligible to be preloaded
176 if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) {
177 if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) {
178 backgroundIframeElement.setAttribute( 'src', backgroundIframe );
179 }
180 }
181
182 }
183
184 }
185
186 this.layout( slide );
187
188 }
189
190 /**
Marc Kupietz09b75752023-10-07 09:32:19 +0200191 * Applies JS-dependent layout helpers for the scope.
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +0200192 */
Marc Kupietz09b75752023-10-07 09:32:19 +0200193 layout( scopeElement ) {
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +0200194
195 // Autosize text with the r-fit-text class based on the
196 // size of its container. This needs to happen after the
197 // slide is visible in order to measure the text.
Marc Kupietz09b75752023-10-07 09:32:19 +0200198 Array.from( scopeElement.querySelectorAll( '.r-fit-text' ) ).forEach( element => {
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +0200199 fitty( element, {
200 minSize: 24,
201 maxSize: this.Reveal.getConfig().height * 0.8,
202 observeMutations: false,
203 observeWindow: false
204 } );
205 } );
206
207 }
208
209 /**
210 * Unloads and hides the given slide. This is called when the
211 * slide is moved outside of the configured view distance.
212 *
213 * @param {HTMLElement} slide
214 */
215 unload( slide ) {
216
217 // Hide the slide element
218 slide.style.display = 'none';
219
220 // Hide the corresponding background element
221 let background = this.Reveal.getSlideBackground( slide );
222 if( background ) {
223 background.style.display = 'none';
224
225 // Unload any background iframes
226 queryAll( background, 'iframe[src]' ).forEach( element => {
227 element.removeAttribute( 'src' );
228 } );
229 }
230
231 // Reset lazy-loaded media elements with src attributes
232 queryAll( slide, 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ).forEach( element => {
233 element.setAttribute( 'data-src', element.getAttribute( 'src' ) );
234 element.removeAttribute( 'src' );
235 } );
236
237 // Reset lazy-loaded media elements with <source> children
238 queryAll( slide, 'video[data-lazy-loaded] source[src], audio source[src]' ).forEach( source => {
239 source.setAttribute( 'data-src', source.getAttribute( 'src' ) );
240 source.removeAttribute( 'src' );
241 } );
242
243 }
244
245 /**
246 * Enforces origin-specific format rules for embedded media.
247 */
248 formatEmbeddedContent() {
249
250 let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => {
251 queryAll( this.Reveal.getSlidesElement(), 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ).forEach( el => {
252 let src = el.getAttribute( sourceAttribute );
253 if( src && src.indexOf( param ) === -1 ) {
254 el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param );
255 }
256 });
257 };
258
259 // YouTube frames must include "?enablejsapi=1"
260 _appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' );
261 _appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' );
262
263 // Vimeo frames must include "?api=1"
264 _appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' );
265 _appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' );
266
267 }
268
269 /**
270 * Start playback of any embedded content inside of
271 * the given element.
272 *
273 * @param {HTMLElement} element
274 */
275 startEmbeddedContent( element ) {
276
277 if( element && !this.Reveal.isSpeakerNotes() ) {
278
279 // Restart GIFs
280 queryAll( element, 'img[src$=".gif"]' ).forEach( el => {
281 // Setting the same unchanged source like this was confirmed
282 // to work in Chrome, FF & Safari
283 el.setAttribute( 'src', el.getAttribute( 'src' ) );
284 } );
285
286 // HTML5 media elements
287 queryAll( element, 'video, audio' ).forEach( el => {
288 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
289 return;
290 }
291
292 // Prefer an explicit global autoplay setting
293 let autoplay = this.Reveal.getConfig().autoPlayMedia;
294
295 // If no global setting is available, fall back on the element's
296 // own autoplay setting
297 if( typeof autoplay !== 'boolean' ) {
298 autoplay = el.hasAttribute( 'data-autoplay' ) || !!closest( el, '.slide-background' );
299 }
300
301 if( autoplay && typeof el.play === 'function' ) {
302
303 // If the media is ready, start playback
304 if( el.readyState > 1 ) {
305 this.startEmbeddedMedia( { target: el } );
306 }
307 // Mobile devices never fire a loaded event so instead
308 // of waiting, we initiate playback
309 else if( isMobile ) {
310 let promise = el.play();
311
312 // If autoplay does not work, ensure that the controls are visible so
313 // that the viewer can start the media on their own
314 if( promise && typeof promise.catch === 'function' && el.controls === false ) {
315 promise.catch( () => {
316 el.controls = true;
317
318 // Once the video does start playing, hide the controls again
319 el.addEventListener( 'play', () => {
320 el.controls = false;
321 } );
322 } );
323 }
324 }
325 // If the media isn't loaded, wait before playing
326 else {
327 el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes
328 el.addEventListener( 'loadeddata', this.startEmbeddedMedia );
329 }
330
331 }
332 } );
333
334 // Normal iframes
335 queryAll( element, 'iframe[src]' ).forEach( el => {
336 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
337 return;
338 }
339
340 this.startEmbeddedIframe( { target: el } );
341 } );
342
343 // Lazy loading iframes
344 queryAll( element, 'iframe[data-src]' ).forEach( el => {
345 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
346 return;
347 }
348
349 if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) {
350 el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes
351 el.addEventListener( 'load', this.startEmbeddedIframe );
352 el.setAttribute( 'src', el.getAttribute( 'data-src' ) );
353 }
354 } );
355
356 }
357
358 }
359
360 /**
361 * Starts playing an embedded video/audio element after
362 * it has finished loading.
363 *
364 * @param {object} event
365 */
366 startEmbeddedMedia( event ) {
367
368 let isAttachedToDOM = !!closest( event.target, 'html' ),
369 isVisible = !!closest( event.target, '.present' );
370
371 if( isAttachedToDOM && isVisible ) {
372 event.target.currentTime = 0;
373 event.target.play();
374 }
375
376 event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia );
377
378 }
379
380 /**
381 * "Starts" the content of an embedded iframe using the
382 * postMessage API.
383 *
384 * @param {object} event
385 */
386 startEmbeddedIframe( event ) {
387
388 let iframe = event.target;
389
390 if( iframe && iframe.contentWindow ) {
391
392 let isAttachedToDOM = !!closest( event.target, 'html' ),
393 isVisible = !!closest( event.target, '.present' );
394
395 if( isAttachedToDOM && isVisible ) {
396
397 // Prefer an explicit global autoplay setting
398 let autoplay = this.Reveal.getConfig().autoPlayMedia;
399
400 // If no global setting is available, fall back on the element's
401 // own autoplay setting
402 if( typeof autoplay !== 'boolean' ) {
403 autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closest( iframe, '.slide-background' );
404 }
405
406 // YouTube postMessage API
407 if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
408 iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' );
409 }
410 // Vimeo postMessage API
411 else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
412 iframe.contentWindow.postMessage( '{"method":"play"}', '*' );
413 }
414 // Generic postMessage API
415 else {
416 iframe.contentWindow.postMessage( 'slide:start', '*' );
417 }
418
419 }
420
421 }
422
423 }
424
425 /**
426 * Stop playback of any embedded content inside of
427 * the targeted slide.
428 *
429 * @param {HTMLElement} element
430 */
431 stopEmbeddedContent( element, options = {} ) {
432
433 options = extend( {
434 // Defaults
435 unloadIframes: true
436 }, options );
437
438 if( element && element.parentNode ) {
439 // HTML5 media elements
440 queryAll( element, 'video, audio' ).forEach( el => {
441 if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) {
442 el.setAttribute('data-paused-by-reveal', '');
443 el.pause();
444 }
445 } );
446
447 // Generic postMessage API for non-lazy loaded iframes
448 queryAll( element, 'iframe' ).forEach( el => {
449 if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
450 el.removeEventListener( 'load', this.startEmbeddedIframe );
451 });
452
453 // YouTube postMessage API
454 queryAll( element, 'iframe[src*="youtube.com/embed/"]' ).forEach( el => {
455 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
456 el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' );
457 }
458 });
459
460 // Vimeo postMessage API
461 queryAll( element, 'iframe[src*="player.vimeo.com/"]' ).forEach( el => {
462 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
463 el.contentWindow.postMessage( '{"method":"pause"}', '*' );
464 }
465 });
466
467 if( options.unloadIframes === true ) {
468 // Unload lazy-loaded iframes
469 queryAll( element, 'iframe[data-src]' ).forEach( el => {
470 // Only removing the src doesn't actually unload the frame
471 // in all browsers (Firefox) so we set it to blank first
472 el.setAttribute( 'src', 'about:blank' );
473 el.removeAttribute( 'src' );
474 } );
475 }
476 }
477
478 }
479
Christophe Dervieux8afae132021-12-06 15:16:42 +0100480}