返回 reveal.js
slidecontent.js
根目录 / js / controllers / slidecontent.js
1 import { extend, queryAll, closest, getMimeTypeFromFile, encodeRFC3986URI } from '../utils/util'
2 import { isMobile } from '../utils/device'
3
4 import fitty from 'fitty';
5
6 /**
7 * Handles loading, unloading and playback of slide
8 * content such as images, videos and iframes.
9 */
10 export default class SlideContent {
11
12 allowedToPlayAudio = null;
13
14 constructor( Reveal ) {
15
16 this.Reveal = Reveal;
17
18 this.startEmbeddedMedia = this.startEmbeddedMedia.bind( this );
19 this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this );
20 this.preventIframeAutoFocus = this.preventIframeAutoFocus.bind( this );
21 this.ensureMobileMediaPlaying = this.ensureMobileMediaPlaying.bind( this );
22
23 this.failedAudioPlaybackTargets = new Set();
24 this.failedVideoPlaybackTargets = new Set();
25 this.failedMutedVideoPlaybackTargets = new Set();
26
27 this.renderMediaPlayButton();
28
29 }
30
31 renderMediaPlayButton() {
32
33 this.mediaPlayButton = document.createElement( 'button' );
34 this.mediaPlayButton.className = 'r-overlay-button r-media-play-button';
35 this.mediaPlayButton.addEventListener( 'click', () => {
36 this.resetTemporarilyMutedMedia();
37
38 const failedTargets = new Set( [
39 ...this.failedAudioPlaybackTargets,
40 ...this.failedVideoPlaybackTargets,
41 ...this.failedMutedVideoPlaybackTargets
42 ] );
43
44 failedTargets.forEach( target => {
45 this.startEmbeddedMedia( { target: target } );
46 } );
47
48 this.clearMediaPlaybackErrors();
49 } );
50
51 }
52
53 /**
54 * Should the given element be preloaded?
55 * Decides based on local element attributes and global config.
56 *
57 * @param {HTMLElement} element
58 */
59 shouldPreload( element ) {
60
61 if( this.Reveal.isScrollView() ) {
62 return true;
63 }
64
65 // Prefer an explicit global preload setting
66 let preload = this.Reveal.getConfig().preloadIframes;
67
68 // If no global setting is available, fall back on the element's
69 // own preload setting
70 if( typeof preload !== 'boolean' ) {
71 preload = element.hasAttribute( 'data-preload' );
72 }
73
74 return preload;
75 }
76
77 /**
78 * Called when the given slide is within the configured view
79 * distance. Shows the slide element and loads any content
80 * that is set to load lazily (data-src).
81 *
82 * @param {HTMLElement} slide Slide to show
83 */
84 load( slide, options = {} ) {
85
86 // Show the slide element
87 const displayValue = this.Reveal.getConfig().display;
88 if( displayValue.includes('!important') ) {
89 const value = displayValue.replace(/\s*!important\s*$/, '').trim();
90 slide.style.setProperty('display', value, 'important');
91 } else {
92 slide.style.display = displayValue;
93 }
94
95 // Media and iframe elements with data-src attributes
96 queryAll( slide, 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ).forEach( element => {
97 const isIframe = element.tagName === 'IFRAME';
98 if( !isIframe || this.shouldPreload( element ) ) {
99 element.setAttribute( 'src', element.getAttribute( 'data-src' ) );
100 element.setAttribute( 'data-lazy-loaded', '' );
101 element.removeAttribute( 'data-src' );
102
103 if( isIframe ) {
104 element.addEventListener( 'load', this.preventIframeAutoFocus );
105 }
106 }
107 } );
108
109 // Media elements with <source> children
110 queryAll( slide, 'video, audio' ).forEach( media => {
111 let sources = 0;
112
113 queryAll( media, 'source[data-src]' ).forEach( source => {
114 source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
115 source.removeAttribute( 'data-src' );
116 source.setAttribute( 'data-lazy-loaded', '' );
117 sources += 1;
118 } );
119
120 // Enable inline video playback in mobile Safari
121 if( isMobile && media.tagName === 'VIDEO' ) {
122 media.setAttribute( 'playsinline', '' );
123 }
124
125 // If we rewrote sources for this video/audio element, we need
126 // to manually tell it to load from its new origin
127 if( sources > 0 ) {
128 media.load();
129 }
130 } );
131
132
133 // Show the corresponding background element
134 let background = slide.slideBackgroundElement;
135 if( background ) {
136 background.style.display = 'block';
137
138 let backgroundContent = slide.slideBackgroundContentElement;
139 let backgroundIframe = slide.getAttribute( 'data-background-iframe' );
140
141 // If the background contains media, load it
142 if( background.hasAttribute( 'data-loaded' ) === false ) {
143 background.setAttribute( 'data-loaded', 'true' );
144
145 let backgroundImage = slide.getAttribute( 'data-background-image' ),
146 backgroundVideo = slide.getAttribute( 'data-background-video' ),
147 backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ),
148 backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' );
149
150 // Images
151 if( backgroundImage ) {
152 // base64
153 if( /^data:/.test( backgroundImage.trim() ) ) {
154 backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`;
155 }
156 // URL(s)
157 else {
158 backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => {
159 // Decode URL(s) that are already encoded first
160 let decoded = decodeURI(background.trim());
161 return `url(${encodeRFC3986URI(decoded)})`;
162 }).join( ',' );
163 }
164 }
165 // Videos
166 else if ( backgroundVideo ) {
167 let video = document.createElement( 'video' );
168
169 if( backgroundVideoLoop ) {
170 video.setAttribute( 'loop', '' );
171 }
172
173 if( backgroundVideoMuted || this.Reveal.isSpeakerNotes() ) {
174 video.muted = true;
175 }
176
177 // Enable inline playback in mobile Safari
178 if( isMobile ) {
179 video.setAttribute( 'playsinline', '' );
180 }
181
182 // Support comma separated lists of video sources
183 backgroundVideo.split( ',' ).forEach( source => {
184 const sourceElement = document.createElement( 'source' );
185 sourceElement.setAttribute( 'src', source );
186
187 let type = getMimeTypeFromFile( source );
188 if( type ) {
189 sourceElement.setAttribute( 'type', type );
190 }
191
192 video.appendChild( sourceElement );
193 } );
194
195 backgroundContent.appendChild( video );
196 }
197 // Iframes
198 else if( backgroundIframe && options.excludeIframes !== true ) {
199 let iframe = document.createElement( 'iframe' );
200 iframe.setAttribute( 'allowfullscreen', '' );
201 iframe.setAttribute( 'mozallowfullscreen', '' );
202 iframe.setAttribute( 'webkitallowfullscreen', '' );
203 iframe.setAttribute( 'allow', 'autoplay' );
204
205 iframe.setAttribute( 'data-src', backgroundIframe );
206
207 iframe.style.width = '100%';
208 iframe.style.height = '100%';
209 iframe.style.maxHeight = '100%';
210 iframe.style.maxWidth = '100%';
211
212 backgroundContent.appendChild( iframe );
213 }
214 }
215
216 // Start loading preloadable iframes
217 let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' );
218 if( backgroundIframeElement ) {
219
220 // Check if this iframe is eligible to be preloaded
221 if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) {
222 if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) {
223 backgroundIframeElement.setAttribute( 'src', backgroundIframe );
224 }
225 }
226
227 }
228
229 }
230
231 this.layout( slide );
232
233 }
234
235 /**
236 * Applies JS-dependent layout helpers for the scope.
237 */
238 layout( scopeElement ) {
239
240 // Autosize text with the r-fit-text class based on the
241 // size of its container. This needs to happen after the
242 // slide is visible in order to measure the text.
243 Array.from( scopeElement.querySelectorAll( '.r-fit-text' ) ).forEach( element => {
244 fitty( element, {
245 minSize: 24,
246 maxSize: this.Reveal.getConfig().height * 0.8,
247 observeMutations: false,
248 observeWindow: false
249 } );
250 } );
251
252 }
253
254 /**
255 * Unloads and hides the given slide. This is called when the
256 * slide is moved outside of the configured view distance.
257 *
258 * @param {HTMLElement} slide
259 */
260 unload( slide ) {
261
262 // Hide the slide element
263 slide.style.display = 'none';
264
265 // Hide the corresponding background element
266 let background = this.Reveal.getSlideBackground( slide );
267 if( background ) {
268 background.style.display = 'none';
269
270 // Unload any background iframes
271 queryAll( background, 'iframe[src]' ).forEach( element => {
272 element.removeAttribute( 'src' );
273 } );
274 }
275
276 // Reset lazy-loaded media elements with src attributes
277 queryAll( slide, 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ).forEach( element => {
278 element.setAttribute( 'data-src', element.getAttribute( 'src' ) );
279 element.removeAttribute( 'src' );
280 } );
281
282 // Reset lazy-loaded media elements with <source> children
283 queryAll( slide, 'video[data-lazy-loaded] source[src], audio source[src]' ).forEach( source => {
284 source.setAttribute( 'data-src', source.getAttribute( 'src' ) );
285 source.removeAttribute( 'src' );
286 } );
287
288 }
289
290 /**
291 * Enforces origin-specific format rules for embedded media.
292 */
293 formatEmbeddedContent() {
294
295 let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => {
296 queryAll( this.Reveal.getSlidesElement(), 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ).forEach( el => {
297 let src = el.getAttribute( sourceAttribute );
298 if( src && src.indexOf( param ) === -1 ) {
299 el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param );
300 }
301 });
302 };
303
304 // YouTube frames must include "?enablejsapi=1"
305 _appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' );
306 _appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' );
307
308 // Vimeo frames must include "?api=1"
309 _appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' );
310 _appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' );
311
312 }
313
314 /**
315 * Start playback of any embedded content inside of
316 * the given element.
317 *
318 * @param {HTMLElement} element
319 */
320 startEmbeddedContent( element ) {
321
322 if( element ) {
323
324 const isSpeakerNotesWindow = this.Reveal.isSpeakerNotes();
325
326 // Restart GIFs
327 queryAll( element, 'img[src$=".gif"]' ).forEach( el => {
328 // Setting the same unchanged source like this was confirmed
329 // to work in Chrome, FF & Safari
330 el.setAttribute( 'src', el.getAttribute( 'src' ) );
331 } );
332
333 // HTML5 media elements
334 queryAll( element, 'video, audio' ).forEach( el => {
335 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
336 return;
337 }
338
339 // Prefer an explicit global autoplay setting
340 let autoplay = this.Reveal.getConfig().autoPlayMedia;
341
342 // If no global setting is available, fall back on the element's
343 // own autoplay setting
344 if( typeof autoplay !== 'boolean' ) {
345 autoplay = el.hasAttribute( 'data-autoplay' ) || !!closest( el, '.slide-background' );
346 }
347
348 if( autoplay && typeof el.play === 'function' ) {
349
350 // In the speaker view we only auto-play muted media
351 if( isSpeakerNotesWindow && !el.muted ) return;
352
353 // If the media is ready, start playback
354 if( el.readyState > 1 ) {
355 this.startEmbeddedMedia( { target: el } );
356 }
357 // Mobile devices never fire a loaded event so instead
358 // of waiting, we initiate playback
359 else if( isMobile ) {
360 el.addEventListener( 'canplay', this.ensureMobileMediaPlaying );
361
362 this.playMediaElement( el );
363 }
364 // If the media isn't loaded, wait before playing
365 else {
366 el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes
367 el.addEventListener( 'loadeddata', this.startEmbeddedMedia );
368 }
369
370 }
371 } );
372
373 // Don't play iframe content in the speaker view since we can't
374 // guarantee that it's muted
375 if( !isSpeakerNotesWindow ) {
376
377 // Normal iframes
378 queryAll( element, 'iframe[src]' ).forEach( el => {
379 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
380 return;
381 }
382
383 this.startEmbeddedIframe( { target: el } );
384 } );
385
386 // Lazy loading iframes
387 queryAll( element, 'iframe[data-src]' ).forEach( el => {
388 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
389 return;
390 }
391
392 if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) {
393 el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes
394 el.addEventListener( 'load', this.startEmbeddedIframe );
395 el.setAttribute( 'src', el.getAttribute( 'data-src' ) );
396 }
397 } );
398
399 }
400
401 }
402
403 }
404
405 /**
406 * Ensure that an HTMLMediaElement is playing on mobile devices.
407 *
408 * This is a workaround for a bug in mobile Safari where
409 * the media fails to display if many videos are started
410 * at the same moment. When this happens, Mobile Safari
411 * reports the video is playing, and the current time
412 * advances, but nothing is visible.
413 *
414 * @param {Event} event
415 */
416 ensureMobileMediaPlaying( event ) {
417
418 const el = event.target;
419
420 // Ignore this check incompatible browsers
421 if( typeof el.getVideoPlaybackQuality !== 'function' ) {
422 return;
423 }
424
425 setTimeout( () => {
426
427 const playing = el.paused === false;
428 const totalFrames = el.getVideoPlaybackQuality().totalVideoFrames;
429
430 if( playing && totalFrames === 0 ) {
431 el.load();
432 el.play();
433 }
434
435 }, 1000 );
436
437 }
438
439 /**
440 * Starts playing an embedded video/audio element after
441 * it has finished loading.
442 *
443 * @param {object} event
444 */
445 startEmbeddedMedia( event ) {
446
447 let isAttachedToDOM = !!closest( event.target, 'html' ),
448 isVisible = !!closest( event.target, '.present' );
449
450 if( isAttachedToDOM && isVisible ) {
451 // Don't restart if media is already playing
452 if( event.target.paused || event.target.ended ) {
453 event.target.currentTime = 0;
454 this.playMediaElement( event.target );
455 }
456 }
457
458 event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia );
459
460 }
461
462 /**
463 * Plays the given HTMLMediaElement and handles any playback
464 * errors, such as the browser not allowing audio to play without
465 * user action.
466 *
467 * @param {HTMLElement} mediaElement
468 */
469 playMediaElement( mediaElement ) {
470
471 const promise = mediaElement.play();
472
473 if( promise && typeof promise.catch === 'function' ) {
474 promise
475 .then( () => {
476 if( !mediaElement.muted ) {
477 this.allowedToPlayAudio = true;
478 }
479 } )
480 .catch( ( error ) => {
481 if( error.name === 'NotAllowedError' ) {
482 this.allowedToPlayAudio = false;
483
484 // If this is a video, we record the error and try to play it
485 // muted as a fallback. The user will be presented with an unmute
486 // button.
487 if( mediaElement.tagName === 'VIDEO' ) {
488 this.onVideoPlaybackNotAllowed( mediaElement );
489
490 let isAttachedToDOM = !!closest( mediaElement, 'html' ),
491 isVisible = !!closest( mediaElement, '.present' ),
492 isMuted = mediaElement.muted;
493
494 if( isAttachedToDOM && isVisible && !isMuted ) {
495 mediaElement.setAttribute( 'data-muted-by-reveal', 'true' );
496 mediaElement.muted = true;
497 mediaElement.play().catch(() => {
498 this.onMutedVideoPlaybackNotAllowed( mediaElement );
499 });
500 }
501 }
502 else if( mediaElement.tagName === 'AUDIO' ) {
503 this.onAudioPlaybackNotAllowed( mediaElement );
504 }
505 }
506 } );
507 }
508
509 }
510
511 /**
512 * "Starts" the content of an embedded iframe using the
513 * postMessage API.
514 *
515 * @param {object} event
516 */
517 startEmbeddedIframe( event ) {
518
519 let iframe = event.target;
520
521 this.preventIframeAutoFocus( event );
522
523 if( iframe && iframe.contentWindow ) {
524
525 let isAttachedToDOM = !!closest( event.target, 'html' ),
526 isVisible = !!closest( event.target, '.present' );
527
528 if( isAttachedToDOM && isVisible ) {
529
530 // Prefer an explicit global autoplay setting
531 let autoplay = this.Reveal.getConfig().autoPlayMedia;
532
533 // If no global setting is available, fall back on the element's
534 // own autoplay setting
535 if( typeof autoplay !== 'boolean' ) {
536 autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closest( iframe, '.slide-background' );
537 }
538
539 // YouTube postMessage API
540 if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
541 iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' );
542 }
543 // Vimeo postMessage API
544 else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
545 iframe.contentWindow.postMessage( '{"method":"play"}', '*' );
546 }
547 // Generic postMessage API
548 else {
549 iframe.contentWindow.postMessage( 'slide:start', '*' );
550 }
551
552 }
553
554 }
555
556 }
557
558 /**
559 * Stop playback of any embedded content inside of
560 * the targeted slide.
561 *
562 * @param {HTMLElement} element
563 */
564 stopEmbeddedContent( element, options = {} ) {
565
566 options = extend( {
567 // Defaults
568 unloadIframes: true
569 }, options );
570
571 if( element && element.parentNode ) {
572 // HTML5 media elements
573 queryAll( element, 'video, audio' ).forEach( el => {
574 if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) {
575 el.setAttribute('data-paused-by-reveal', '');
576 el.pause();
577
578 if( isMobile ) {
579 el.removeEventListener( 'canplay', this.ensureMobileMediaPlaying );
580 }
581 }
582 } );
583
584 // Generic postMessage API for non-lazy loaded iframes
585 queryAll( element, 'iframe' ).forEach( el => {
586 if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
587 el.removeEventListener( 'load', this.preventIframeAutoFocus );
588 el.removeEventListener( 'load', this.startEmbeddedIframe );
589 });
590
591 // YouTube postMessage API
592 queryAll( element, 'iframe[src*="youtube.com/embed/"]' ).forEach( el => {
593 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
594 el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' );
595 }
596 });
597
598 // Vimeo postMessage API
599 queryAll( element, 'iframe[src*="player.vimeo.com/"]' ).forEach( el => {
600 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
601 el.contentWindow.postMessage( '{"method":"pause"}', '*' );
602 }
603 });
604
605 if( options.unloadIframes === true ) {
606 // Unload lazy-loaded iframes
607 queryAll( element, 'iframe[data-src]' ).forEach( el => {
608 // Only removing the src doesn't actually unload the frame
609 // in all browsers (Firefox) so we set it to blank first
610 el.setAttribute( 'src', 'about:blank' );
611 el.removeAttribute( 'src' );
612 } );
613 }
614 }
615
616 }
617
618 /**
619 * Checks whether media playback is blocked by the browser. This
620 * typically happens when media playback is initiated without a
621 * direct user interaction.
622 */
623 isAllowedToPlayAudio() {
624
625 return this.allowedToPlayAudio;
626
627 }
628
629 /**
630 * Shows a manual button in situations where autoamtic media playback
631 * is not allowed by the browser.
632 */
633 showPlayOrUnmuteButton() {
634
635 const audioTargets = this.failedAudioPlaybackTargets.size;
636 const videoTargets = this.failedVideoPlaybackTargets.size;
637 const mutedVideoTargets = this.failedMutedVideoPlaybackTargets.size;
638
639 let label = 'Play media';
640
641 if( mutedVideoTargets > 0 ) {
642 label = 'Play video';
643 }
644 else if( videoTargets > 0 ) {
645 label = 'Unmute video';
646 }
647 else if( audioTargets > 0 ) {
648 label = 'Play audio';
649 }
650
651 this.mediaPlayButton.textContent = label;
652
653 this.Reveal.getRevealElement().appendChild( this.mediaPlayButton );
654
655 }
656
657 onAudioPlaybackNotAllowed( target ) {
658
659 this.failedAudioPlaybackTargets.add( target );
660 this.showPlayOrUnmuteButton( target );
661
662 }
663
664 onVideoPlaybackNotAllowed( target ) {
665
666 this.failedVideoPlaybackTargets.add( target );
667 this.showPlayOrUnmuteButton();
668
669 }
670
671 onMutedVideoPlaybackNotAllowed( target ) {
672
673 this.failedMutedVideoPlaybackTargets.add( target );
674 this.showPlayOrUnmuteButton();
675
676 }
677
678 /**
679 * Videos may be temporarily muted by us to get around browser
680 * restrictions on automatic playback. This method rolls back
681 * all such temporary audio changes.
682 */
683 resetTemporarilyMutedMedia() {
684
685 const failedTargets = new Set( [
686 ...this.failedAudioPlaybackTargets,
687 ...this.failedVideoPlaybackTargets,
688 ...this.failedMutedVideoPlaybackTargets
689 ] );
690
691 failedTargets.forEach( target => {
692 if( target.hasAttribute( 'data-muted-by-reveal' ) ) {
693 target.muted = false;
694 target.removeAttribute( 'data-muted-by-reveal' );
695 }
696 } );
697
698 }
699
700 clearMediaPlaybackErrors() {
701
702 this.resetTemporarilyMutedMedia();
703
704 this.failedAudioPlaybackTargets.clear();
705 this.failedVideoPlaybackTargets.clear();
706 this.failedMutedVideoPlaybackTargets.clear();
707
708 if( this.mediaPlayButton && this.mediaPlayButton.parentNode ) {
709 this.mediaPlayButton.remove();
710 }
711
712 }
713
714 /**
715 * Prevents iframes from automatically focusing themselves.
716 *
717 * @param {Event} event
718 */
719 preventIframeAutoFocus( event ) {
720
721 const iframe = event.target;
722
723 if( iframe && this.Reveal.getConfig().preventIframeAutoFocus ) {
724
725 let elapsed = 0;
726 const interval = 100;
727 const maxTime = 1000;
728 const checkFocus = () => {
729 if( document.activeElement === iframe ) {
730 document.activeElement.blur();
731 } else if( elapsed < maxTime ) {
732 elapsed += interval;
733 setTimeout( checkFocus, interval );
734 }
735 };
736
737 setTimeout( checkFocus, interval );
738
739 }
740
741 }
742
743 afterSlideChanged() {
744
745 this.clearMediaPlaybackErrors();
746
747 }
748
749 }
750
750 lines JAVASCRIPT