PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-lazy-loader.php +380 -32 1.3.6 → 1.4.1 View file →
@@ -34,8 +34,38 @@
34 34 */
35 35 private static $image_counter = 0;
36 36
37 37 /**
38 + * Whether an image on this page already holds fetchpriority="high".
39 + *
40 + * One per page. Every eager image used to get it, so with eager_first_n
41 + * at 3 an accordion's hidden images fetched at High alongside the
42 + * render-blocking CSS and the real LCP image. (#558)
43 + *
44 + * @var bool
45 + */
46 + private static $priority_claimed = false;
47 +
48 + /**
49 + * Byte spans of markup that is hidden on arrival in the chunk the image
50 + * pass is walking, and whether the current tag sits in one.
51 + *
52 + * @var array<int, array{0:int, 1:int}>
53 + */
54 + private static $hidden_ranges = array();
55 +
56 + /** @var bool */
57 + private static $in_hidden = false;
58 +
59 + /**
60 + * Whether a visible excluded image is still to come, so the eager budget
61 + * must not take the high slot first.
62 + *
63 + * @var bool
64 + */
65 + private static $priority_reserved = false;
66 +
67 + /**
38 68 * Eager budget for inline-style backgrounds. Separate from images: a
39 69 * hero is either an <img> or a background, and the passes run one after
40 70 * the other, so one shared counter would hand the budget to whichever
41 71 * pass runs first rather than to what sits first on the page.
@@ -102,9 +132,9 @@
102 132 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
103 133 };
104 134
105 135 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
106 - $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) );
136 + $work = self::apply_img_pass( $work, $tag_re( 'img' ), $opts );
107 137 }
108 138 if ( ! empty( $opts['lazy_iframes'] ) ) {
109 139 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
110 140 }
@@ -282,8 +312,149 @@
282 312 return null;
283 313 }
284 314
285 315 /**
316 + * The <img> pass, which also knows which tags sit in hidden markup.
317 + *
318 + * Hidden spans are only worked out while eager slots remain: past the
319 + * budget every image is lazy anyway, and the scan costs a pass per
320 + * hidden container.
321 + *
322 + * @param array<string,mixed> $opts Lazy settings.
323 + */
324 + private static function apply_img_pass( string $html, string $pattern, array $opts ): string {
325 + // Core picks one content image for fetchpriority="high" at priority
326 + // 12, before this pass. That pick is the page's, so ours stands down.
327 + if ( ! self::$priority_claimed && preg_match( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?<![-\w])fetchpriority\s*=\s*["\']?high\b#i', $html ) ) {
328 + self::$priority_claimed = true;
329 + }
330 +
331 + $budget = max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
332 + self::$hidden_ranges = ( ! empty( $opts['lazy_images'] ) && ( self::$image_counter < $budget || ! empty( $opts['excluded_images'] ) ) )
333 + ? self::hidden_ranges( $html )
334 + : array();
335 +
336 + // An image the user named in the exclusions is the hero they chose.
337 + // When the chunk holds a visible one, the eager budget leaves the high
338 + // slot to it rather than to an icon printed ahead of it.
339 + if ( ! self::$priority_claimed && ! self::$priority_reserved && ! empty( $opts['lazy_images'] ) && ! empty( $opts['excluded_images'] )
340 + && preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $imgs, PREG_OFFSET_CAPTURE ) ) {
341 + foreach ( $imgs[0] as $img ) {
342 + if ( ! self::is_excluded( (string) $img[0], $opts ) || self::tag_is_hidden( (string) $img[0], 'img' ) || self::offset_hidden( (int) $img[1] ) ) {
343 + continue;
344 + }
345 + self::$priority_reserved = true;
346 + break;
347 + }
348 + }
349 +
350 + if ( empty( self::$hidden_ranges ) ) {
351 + return self::apply_pass( $html, $pattern, array( __CLASS__, 'rewrite_img' ) );
352 + }
353 +
354 + $ranges = self::$hidden_ranges;
355 + $result = preg_replace_callback(
356 + $pattern,
357 + static function ( array $m ) use ( $ranges ): string {
358 + $offset = (int) $m[0][1];
359 + self::$in_hidden = false;
360 + foreach ( $ranges as $range ) {
361 + if ( $offset >= $range[0] && $offset < $range[1] ) {
362 + self::$in_hidden = true;
363 + break;
364 + }
365 + }
366 + $out = self::rewrite_img( array( (string) $m[0][0] ) );
367 + self::$in_hidden = false;
368 + return $out;
369 + },
370 + $html,
371 + -1,
372 + $count,
373 + PREG_OFFSET_CAPTURE
374 + );
375 + self::$hidden_ranges = array();
376 +
377 + return is_string( $result ) ? $result : $html;
378 + }
379 +
380 + /** Whether a byte offset in the chunk being walked sits in hidden markup. */
381 + private static function offset_hidden( int $offset ): bool {
382 + foreach ( self::$hidden_ranges as $range ) {
383 + if ( $offset >= $range[0] && $offset < $range[1] ) {
384 + return true;
385 + }
386 + }
387 + return false;
388 + }
389 +
390 + /**
391 + * Byte spans of elements the markup itself hides: a closed <details>,
392 + * the `hidden` attribute, or an inline `display:none`.
393 + *
394 + * Only what the tag says. Panels a stylesheet or script hides (most
395 + * accordion and tab blocks) look visible from here.
396 + *
397 + * Pure. Resource_Hints_Processor runs it over the whole page, so an
398 + * image the lazy pass kept out of the high slot doesn't get it back
399 + * from the page-wide pass.
400 + *
401 + * @return array<int, array{0:int, 1:int}>
402 + */
403 + public static function hidden_ranges( string $html ): array {
404 + if ( false === stripos( $html, '<details' ) && false === stripos( $html, 'hidden' ) && ! preg_match( '#display\s*:\s*none#i', $html ) ) {
405 + return array();
406 + }
407 + if ( ! preg_match_all( '#<([a-z][a-z0-9-]*)\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
408 + return array();
409 + }
410 +
411 + $ranges = array();
412 + $until = -1;
413 + foreach ( $m[0] as $i => $hit ) {
414 + $start = (int) $hit[1];
415 + if ( $start < $until ) {
416 + continue; // Inside a span already found.
417 + }
418 + $tag = (string) $hit[0];
419 + $name = strtolower( (string) $m[1][ $i ][0] );
420 + if ( 'img' === $name || ! self::tag_is_hidden( $tag, $name ) ) {
421 + continue;
422 + }
423 + $end = self::same_tag_end( $html, $start, $name );
424 + if ( null === $end ) {
425 + continue;
426 + }
427 + $until = $end;
428 + // A closed <details> still shows its own <summary>, the child
429 + // that opens it. A nested <details>' summary further in is not it.
430 + if ( 'details' === $name ) {
431 + $inner = $start + strlen( $tag );
432 + if ( preg_match( '#^\s*<summary\b.*?</summary\s*>#is', substr( $html, $inner, $end - $inner ), $sm ) ) {
433 + $start = $inner + strlen( (string) $sm[0] );
434 + }
435 + }
436 + $ranges[] = array( $start, $end );
437 + }
438 + return $ranges;
439 + }
440 +
441 + /** Pure: whether one opening tag hides its contents. */
442 + public static function tag_is_hidden( string $tag, string $name ): bool {
443 + // Attribute names only. Blanking the quoted values first keeps
444 + // class="is-hidden" and aria-hidden="true" from reading as `hidden`.
445 + $names = (string) preg_replace( '#=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)#', '', $tag );
446 + if ( 'details' === $name ) {
447 + return ! preg_match( '#(?<![-\w])open(?![-\w])#i', $names );
448 + }
449 + if ( preg_match( '#\shidden(?![-\w])#i', $names ) ) {
450 + return true;
451 + }
452 + return preg_match( '#(?<![-\w])style\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', $tag, $style )
453 + && preg_match( '#(?<![-\w])display\s*:\s*none#i', $style[1] );
454 + }
455 +
456 + /**
286 457 * Run one rewrite pass, keeping the input if PCRE bails.
287 458 *
288 459 * preg_replace_callback() returns null when it hits the backtrack or
289 460 * recursion limit — on a large page that would otherwise blank the
@@ -323,8 +494,10 @@
323 494 || false !== stripos( $tag, 'data-no-lazy' )
324 495 || self::has_high_fetchpriority( $tag )
325 496 || self::is_excluded( $tag, $opts );
326 497
498 + $self_hidden = self::tag_is_hidden( $tag, 'img' );
499 +
327 500 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
328 501 // An EXCLUDED image is one the user marked as above-the-fold (a
329 502 // hero/logo) — the opposite of lazy. WordPress core adds
330 503 // `loading="lazy"` to images by default (since 5.5), so merely
@@ -331,24 +504,46 @@
331 504 // *skipping* our lazy pass would leave core's lazy attribute on
332 505 // the LCP hero and tank LCP. Actively make it eager +
333 506 // high-priority so an excluded hero loads immediately.
334 507 $tag = self::set_attr( $tag, 'loading', 'eager' );
335 - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
508 + // Still one high image per page: every excluded logo and
509 + // data-skip-lazy icon used to get it too. A data-skip-lazy icon
510 + // printed ahead of a reserved excluded hero leaves the slot to
511 + // the hero, as the eager budget does. (#558)
512 + $may_take = ! self::$priority_reserved || self::is_excluded( $tag, $opts );
513 + if ( ! self::$priority_claimed && ! self::$in_hidden && ! $self_hidden && $may_take ) {
514 + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
515 + }
516 + if ( self::has_high_fetchpriority( $tag ) ) {
517 + self::$priority_claimed = true;
518 + }
336 519 $tag = self::set_attr( $tag, 'decoding', 'async', true );
520 + } elseif ( ! empty( $opts['lazy_images'] ) && $self_hidden ) {
521 + // A display:none image (a tracking pixel, most often) is not above
522 + // the fold, so it must not take the hero's eager slot. Its loading
523 + // is left alone: a lazy display:none image never loads, and the
524 + // pixel would stop counting. (#558)
525 + $tag = self::set_attr( $tag, 'decoding', 'async', true );
337 526 } elseif ( ! empty( $opts['lazy_images'] ) ) {
338 527 // Above-the-fold skip: first N images get loading="eager"
339 528 // instead of "lazy" so the LCP image isn't deferred. Only
340 - // non-excluded images consume the budget.
341 - self::$image_counter++;
342 - $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
343 - $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
344 - $tag = self::set_attr( $tag, 'decoding', 'async', true );
345 - // The eager hero should also drop any core `loading="lazy"`; the
346 - // set_attr above already overrode it. Give the first eager image
347 - // high fetch priority so it wins the LCP race.
348 - if ( $is_above_fold ) {
349 - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
529 + // non-excluded images consume the budget, and an image in
530 + // markup that is hidden on arrival is not above the fold. (#558)
531 + $is_above_fold = false;
532 + if ( ! self::$in_hidden ) {
533 + self::$image_counter++;
534 + $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
350 535 }
536 + $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
537 + $tag = self::set_attr( $tag, 'decoding', 'async', true );
538 + // Only the first eager image gets high priority. The rest of the
539 + // eager budget skips lazy loading and no more: more than one
540 + // High image competes with the render-blocking CSS, and the LCP
541 + // is rarely the third image in the document. (#558)
542 + if ( $is_above_fold && ! self::$priority_claimed && ! self::$priority_reserved ) {
543 + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
544 + self::$priority_claimed = true;
545 + }
351 546 }
352 547
353 548 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
354 549 $tag = self::ensure_dimensions( $tag );
@@ -519,11 +714,16 @@
519 714 }
520 715
521 716 private static function rewrite_video( array $m ): string {
522 717 $tag = $m[0];
523 - if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
718 + // data-no-lazy is the other common opt-out spelling; the restorer
719 + // honours both for players that build their <video> from script.
720 + if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' ) ) {
524 721 return $tag;
525 722 }
723 + if ( self::is_excluded( $tag, self::opts() ) ) {
724 + return $tag;
725 + }
526 726 /*
527 727 * An autoplaying video is the one case preload="none" cannot help:
528 728 * browsers fetch an autoplay source regardless of preload, because
529 729 * the author asked for it to start on its own. Setting the attribute
@@ -581,9 +781,14 @@
581 781
582 782 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
583 783 return $element;
584 784 }
585 - if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
785 + // The lazy pass runs first and renames `autoplay` to
786 + // data-xspeed-autoplay when it defers the source, so check both.
787 + // Checking only the attribute turned every deferred autoplay video
788 + // with a poster (a Kadence row background, a hero) into a play
789 + // button.
790 + if ( preg_match( '#\s(?:data-xspeed-)?autoplay(?=[\s/>=])#i', $tag ) ) {
586 791 return $element;
587 792 }
588 793 if ( self::is_excluded( $tag, self::opts() ) ) {
589 794 return $element;
@@ -646,8 +851,29 @@
646 851 if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
647 852 return $tag;
648 853 }
649 854
855 + // A background video waits for the visitor's first interaction, not
856 + // just the viewport. It is decoration, and on a hero it is in the
857 + // viewport at once, so the viewport rule loaded it immediately and its
858 + // first frame became the LCP. Held back, the hero text is the LCP and
859 + // the video starts on the first scroll, tap, key or mouse move.
860 + // Checked before autoplay is renamed below.
861 + if ( self::is_background_video_without_poster( $tag ) ) {
862 + /**
863 + * Whether a background video waits for the first interaction.
864 + *
865 + * Return false to load it when it reaches the viewport instead,
866 + * like any other autoplay video.
867 + *
868 + * @param bool $wait Whether the video waits. Default true.
869 + * @param string $tag The <video> opening tag.
870 + */
871 + if ( (bool) apply_filters( 'xspeed_lazy_background_video_waits_for_interaction', true, $tag ) ) {
872 + $tag = self::set_attr( $tag, 'data-xspeed-wait', 'interaction' );
873 + }
874 + }
875 +
650 876 $deferred = false;
651 877
652 878 // The element's own src, when it has one.
653 879 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
@@ -682,8 +908,37 @@
682 908 return $tag;
683 909 }
684 910
685 911 /**
912 + * Whether a <video> opening tag is a decorative background with no poster.
913 + *
914 + * Autoplay, muted, looping and without controls is how every builder
915 + * marks a background video: nobody watches it, it sits behind the hero
916 + * text. With no poster, nothing paints in its box until the first frame
917 + * decodes, so on a hero the video's first frame becomes the page's LCP.
918 + * On the measured site that was a 5 MB MP4 and a 3.5–3.9 s mobile LCP,
919 + * against 2.3 s with the video out of the way.
920 + *
921 + * Reads autoplay in both spellings: the author's `autoplay`, and the
922 + * `data-xspeed-autoplay` the lazy pass leaves when it defers the source.
923 + * Resource Hints sees the tag after that pass has run.
924 + *
925 + * @param string $tag A <video> opening tag.
926 + */
927 + public static function is_background_video_without_poster( string $tag ): bool {
928 + $has = static function ( string $name ) use ( $tag ): bool {
929 + return (bool) preg_match( '#\s' . $name . '(?=[\s/>=])#i', $tag );
930 + };
931 + if ( ! $has( 'autoplay' ) && ! $has( 'data-xspeed-autoplay' ) ) {
932 + return false;
933 + }
934 + if ( ! $has( 'muted' ) || ! $has( 'loop' ) || $has( 'controls' ) ) {
935 + return false;
936 + }
937 + return ! preg_match( '#\sposter\s*=\s*(?:["\']\s*)?[^"\'\s>]#i', $tag );
938 + }
939 +
940 + /**
686 941 * Did this response defer at least one autoplay video? Gates the script
687 942 * so a page with no such video ships no extra bytes.
688 943 *
689 944 * @var bool
@@ -736,13 +991,38 @@
736 991 *
737 992 * Dependency-free and tiny, matching Video_Facade::facade_script(). The
738 993 * rootMargin starts the fetch slightly before the element is visible so
739 994 * playback begins without a visible stall.
995 + *
996 + * @param bool $after_load Hold autoplay videos until `load` even when they
997 + * are on screen from the start (video_after_load).
998 + * A hero video otherwise starts downloading with
999 + * the page and competes with everything the first
1000 + * paint needs; lab tools also count its bytes
1001 + * toward LCP because the request starts before it.
1002 + * @param string[] $excluded Excluded Images patterns. The server pass
1003 + * leaves a matching <video> alone, but it is still
1004 + * a video[autoplay] to adopt(), so the script has
1005 + * to know them too, and they are the only route
1006 + * for a video a player builds in script.
740 1007 */
741 - public static function autoplay_script(): string {
742 - return <<<'JS'
1008 + public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string {
1009 + $script = <<<'JS'
743 1010 (function(){
744 1011 var S='video[data-xspeed-src],video[data-xspeed-defer-sources]';
1012 +var AL=0,Q=[],X=[];
1013 +// Autoplay the author asked for, whether the server pass has renamed it yet
1014 +// or not. A player that builds its video in script sets the real attribute.
1015 +function ap(v){return v.hasAttribute('autoplay')||v.hasAttribute('data-xspeed-autoplay');}
1016 +// Matched like the server pass: a case-insensitive substring of the opening
1017 +// tag, plus the source being set, which is not in the tag yet when a player
1018 +// assigns it.
1019 +function excl(v,val){
1020 +if(!X.length)return false;
1021 +var t=(v.outerHTML.split('>')[0]+' '+(val||'')).toLowerCase();
1022 +for(var i=0;i<X.length;i++){if(t.indexOf(X[i])>=0)return true;}
1023 +return false;
1024 +}
745 1025
746 1026 /*
747 1027 * Intercept the ASSIGNMENT, because observing the DOM is always too late.
748 1028 *
@@ -767,8 +1047,11 @@
767 1047 var hold=function(el,val){
768 1048 if(el.tagName!=='VIDEO')return false;
769 1049 if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
770 1050 if(!val)return false;
1051 +if(el.hasAttribute('data-skip-lazy')||el.hasAttribute('data-no-lazy')||excl(el,val))return false;
1052 +// Already on screen: a player is loading it because it is visible (#548).
1053 +if(el.isConnected&&near(el)&&!(AL&&document.readyState!=='complete'&&ap(el)))return false;
771 1054 el.setAttribute('data-xspeed-src',String(val));
772 1055 el.setAttribute('data-xspeed-adopted','1');
773 1056 return true;
774 1057 };
@@ -788,14 +1071,20 @@
788 1071 };
789 1072 }catch(e){}
790 1073 function go(v){
791 1074 if(v.getAttribute('data-xspeed-loaded'))return;
1075 +// video_after_load: an autoplay video that is on screen before `load` waits
1076 +// for it. readyState is already 'complete' inside the load handler, so the
1077 +// replay below goes straight through.
1078 +// A video built in script keeps its real autoplay attribute when hold()
1079 +// takes its source, so check both spellings.
1080 +if(AL&&document.readyState!=='complete'&&ap(v)){if(Q.indexOf(v)<0)Q.push(v);return;}
792 1081 v.setAttribute('data-xspeed-loaded','1');
793 -var s=v.getAttribute('data-xspeed-src');
794 -if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');}
1082 +var r=0,s=v.getAttribute('data-xspeed-src');
1083 +if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');r=1;}
795 1084 if(v.getAttribute('data-xspeed-defer-sources')){
796 1085 var c=v.querySelectorAll('source[data-xspeed-src]');
797 -for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');}
1086 +for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');r=1;}
798 1087 v.removeAttribute('data-xspeed-defer-sources');
799 1088 }
800 1089
801 1090 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
@@ -800,10 +1089,13 @@
800 1089
801 1090 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
802 1091 v.removeAttribute('preload');
803 1092 // load() picks up the sources we just restored; without it a <video>
804 -// that has already failed to resolve a source will not retry.
805 -if(v.load)v.load();
1093 +// that has already failed to resolve a source will not retry. Only when
1094 +// something was restored: a player that set its own src on the element
1095 +// meanwhile (Elementor's background video) is already fetching it, and
1096 +// load() would abort that request and start it again.
1097 +if(v.load&&(r||!v.getAttribute('src')))v.load();
806 1098 }
807 1099 // A multi-format <video> carries no src of its own — the <source> children
808 1100 // do, and those sit outside the opening tag PHP rewrote. Strip them here,
809 1101 // as early as this script runs, then restore on intersect like the rest.
@@ -832,8 +1124,16 @@
832 1124 var a=document.querySelectorAll('video[autoplay]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="auto"]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="metadata"]:not([data-xspeed-loaded]):not([data-xspeed-adopted])');
833 1125 for(var i=0;i<a.length;i++){
834 1126 var v=a[i];
835 1127 v.setAttribute('data-xspeed-adopted','1');
1128 +// The author's opt-outs and Excluded Images, same as the server pass.
1129 +if(v.hasAttribute('data-skip-lazy')||v.hasAttribute('data-no-lazy')||excl(v))continue;
1130 +// A visible video already in or near the viewport is being started because
1131 +// it is visible — a player with its own lazy loader sets autoplay or preload
1132 +// at exactly that moment (Essential Blocks 6.4.7+). Taking its source then
1133 +// aborted the request and rejected play(). Hold it only when the site asked
1134 +// for autoplay to wait until load and load has not happened yet. (#548)
1135 +if(near(v)&&!(AL&&document.readyState!=='complete'&&ap(v)))continue;
836 1136 var auto=v.hasAttribute('autoplay');
837 1137 var s=v.getAttribute('src');
838 1138 if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
839 1139 var c=v.querySelectorAll('source[src]');
@@ -844,36 +1144,80 @@
844 1144 // asked for.
845 1145 if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
846 1146 v.setAttribute('preload','none');
847 1147 if(v.load)v.load();
1148 +// Watch it now, not on the next full scan: a page whose DOM never goes
1149 +// quiet (typed headings, counters, chat widgets) could otherwise keep a
1150 +// held video blank long after it scrolled into view. (#548)
1151 +watch(v);
848 1152 }
849 1153 }
1154 +// A background video (data-xspeed-wait) that reaches the viewport is parked
1155 +// here until the visitor first scrolls, taps, types or moves the mouse, then
1156 +// every parked one starts together. See defer_autoplay_source().
1157 +var I=false,P=[],E=['pointerdown','pointermove','touchstart','keydown','wheel','scroll'];
1158 +function interacted(){
1159 +if(I)return;I=true;
1160 +for(var i=0;i<E.length;i++)removeEventListener(E[i],interacted,true);
1161 +for(var j=0;j<P.length;j++)go(P[j]);
1162 +P=[];
1163 +}
1164 +for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true});
1165 +function reach(v){if(!I&&v.getAttribute('data-xspeed-wait'))P.push(v);else go(v);}
1166 +// In or within 200px of the viewport, and laid out (a hidden popup's video
1167 +// measures 0x0 at the top of the page and must not count as visible).
1168 +function near(v){
1169 +var r=v.getBoundingClientRect();
1170 +if(!r.width&&!r.height)return false;
1171 +var h=window.innerHeight||document.documentElement.clientHeight;
1172 +return r.bottom>-200&&r.top<h+200;
1173 +}
1174 +// One observer for every held video, created on first use.
1175 +var o;
1176 +function io(){
1177 +if(o!==undefined)return o;
1178 +o=('IntersectionObserver'in window)?new IntersectionObserver(function(es){
1179 +for(var i=0;i<es.length;i++){if(es[i].isIntersecting){o.unobserve(es[i].target);reach(es[i].target);}}
1180 +},{rootMargin:'200px'}):null;
1181 +return o;
1182 +}
1183 +function watch(v){var ob=io();if(ob)ob.observe(v);else reach(v);}
850 1184 function scan(){
851 1185 strip();
852 1186 adopt();
853 1187 var v=document.querySelectorAll(S);
854 -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;}
855 -var o=new IntersectionObserver(function(es){
856 -for(var i=0;i<es.length;i++){if(es[i].isIntersecting){go(es[i].target);o.unobserve(es[i].target);}}
857 -},{rootMargin:'200px'});
858 -for(var j=0;j<v.length;j++)o.observe(v[j]);
1188 +for(var j=0;j<v.length;j++)watch(v[j]);
859 1189 }
860 1190 if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
861 1191 // Players that build their <video> after load (page-builder video blocks)
862 -// must be caught the INSTANT the element lands. A debounce loses the race:
863 -// the browser begins fetching as soon as a src is set, so by the time a
864 -// timer fires the bytes are already committed. adopt() is idempotent and
1192 +// must be caught the INSTANT the element lands. A timer loses the race: the
1193 +// browser begins fetching as soon as a src is set. adopt() is idempotent and
865 1194 // cheap (one guarded querySelectorAll), so run it synchronously on every
866 -// mutation and only debounce the fuller scan that attaches observers.
1195 +// mutation. The fuller scan is THROTTLED, not debounced: a debounce reset by
1196 +// every mutation never fired on a page that keeps changing. (#548)
867 1197 if(window.MutationObserver){
868 -var t;
1198 +var t=0;
869 1199 new MutationObserver(function(){
870 1200 adopt();
871 -clearTimeout(t);t=setTimeout(scan,200);
1201 +if(!t)t=setTimeout(function(){t=0;scan();},200);
872 1202 }).observe(document.documentElement,{childList:true,subtree:true});
873 1203 }
1204 +if(AL)window.addEventListener('load',function(){
1205 +var d=0;function rel(){if(d)return;d=1;var q=Q;Q=[];for(var i=0;i<q.length;i++)go(q[i]);}
1206 +if(window.requestAnimationFrame)requestAnimationFrame(function(){requestAnimationFrame(rel);});
1207 +setTimeout(rel,1500);
1208 +});
874 1209 })();
875 1210 JS;
1211 + $patterns = array();
1212 + foreach ( $excluded as $pattern ) {
1213 + $pattern = strtolower( trim( (string) $pattern ) );
1214 + if ( '' !== $pattern ) {
1215 + $patterns[] = $pattern;
1216 + }
1217 + }
1218 + $vars = 'var AL=' . ( $after_load ? '1' : '0' ) . ',Q=[],X=' . wp_json_encode( array_values( array_unique( $patterns ) ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
1219 + return str_replace( 'var AL=0,Q=[],X=[];', $vars, $script );
876 1220 }
877 1221
878 1222 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
879 1223 // Lookbehind, not `\b`: writing `width` onto a tag carrying
@@ -1622,8 +1966,12 @@
1622 1966 */
1623 1967 public static function reset_state(): void {
1624 1968 self::$opts = null;
1625 1969 self::$image_counter = 0;
1970 + self::$priority_claimed = false;
1971 + self::$hidden_ranges = array();
1972 + self::$in_hidden = false;
1973 + self::$priority_reserved = false;
1626 1974 self::$background_counter = 0;
1627 1975 self::$src_dims_cache = null;
1628 1976 self::$facade_used = false;
1629 1977 self::$warming = false;