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