| @@ -34,8 +34,48 @@ | ||
| 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 | + /** | |
| 68 | + * Eager budget for inline-style backgrounds. Separate from images: a | |
| 69 | + * hero is either an <img> or a background, and the passes run one after | |
| 70 | + * the other, so one shared counter would hand the budget to whichever | |
| 71 | + * pass runs first rather than to what sits first on the page. | |
| 72 | + * | |
| 73 | + * @var int | |
| 74 | + */ | |
| 75 | + private static $background_counter = 0; | |
| 76 | + | |
| 77 | + /** | |
| 38 | 78 | * Settings cache (one read per request). |
| 39 | 79 | * |
| 40 | 80 | * @var array|null |
| 41 | 81 | */ |
| @@ -92,13 +132,18 @@ | ||
| 92 | 132 | return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i'; |
| 93 | 133 | }; |
| 94 | 134 | |
| 95 | 135 | if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) { |
| 96 | - $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) ); | |
| 136 | + $work = self::apply_img_pass( $work, $tag_re( 'img' ), $opts ); | |
| 97 | 137 | } |
| 98 | 138 | if ( ! empty( $opts['lazy_iframes'] ) ) { |
| 99 | 139 | $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) ); |
| 100 | 140 | } |
| 141 | + // Every opening tag is a candidate, so skip the pass on content with | |
| 142 | + // no url( at all, which is most of it. | |
| 143 | + if ( ! empty( $opts['lazy_background_images'] ) && false !== stripos( $work, 'url(' ) ) { | |
| 144 | + $work = self::apply_pass( $work, '#<[a-z][a-z0-9-]*\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', array( __CLASS__, 'rewrite_background' ) ); | |
| 145 | + } | |
| 101 | 146 | // Facade runs AFTER the lazy pass, deliberately. The facade keeps the |
| 102 | 147 | // original tag inside <noscript> as the JS-less fallback, and that |
| 103 | 148 | // fallback should carry loading="lazy" too — running this first would |
| 104 | 149 | // produce an eager iframe for exactly the visitors least able to |
| @@ -113,9 +158,9 @@ | ||
| 113 | 158 | // unclosed iframe can't make the match run on to a LATER embed's |
| 114 | 159 | // closing tag and eat everything in between; an iframe with no |
| 115 | 160 | // closing tag simply doesn't match and passes through untouched. |
| 116 | 161 | if ( ! empty( $opts['video_facade'] ) ) { |
| 117 | - $work = self::apply_pass( | |
| 162 | + $work = self::apply_facade_pass( | |
| 118 | 163 | $work, |
| 119 | 164 | '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is', |
| 120 | 165 | array( __CLASS__, 'rewrite_iframe_facade' ) |
| 121 | 166 | ); |
| @@ -133,9 +178,9 @@ | ||
| 133 | 178 | // <noscript> as the JS-less fallback, and that copy should carry |
| 134 | 179 | // preload="none" too. Same whole-element, tempered match so an |
| 135 | 180 | // unclosed <video> passes through rather than eating siblings. |
| 136 | 181 | if ( ! empty( $opts['video_facade'] ) ) { |
| 137 | - $work = self::apply_pass( | |
| 182 | + $work = self::apply_facade_pass( | |
| 138 | 183 | $work, |
| 139 | 184 | '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is', |
| 140 | 185 | array( __CLASS__, 'rewrite_video_facade' ) |
| 141 | 186 | ); |
| @@ -144,8 +189,272 @@ | ||
| 144 | 189 | return self::restore_safe_blocks( $work, $stubs ); |
| 145 | 190 | } |
| 146 | 191 | |
| 147 | 192 | /** |
| 193 | + * Hidden popup/lightbox templates a facade must never replace into. | |
| 194 | + * | |
| 195 | + * A lightbox plugin ships its player iframe in a hidden template div | |
| 196 | + * and moves that markup into the popup when clicked. Facading the | |
| 197 | + * template swaps its iframe for the play button, and the popup then | |
| 198 | + * opens around a button its own CSS only sizes for an iframe — a blank | |
| 199 | + * modal (found live: EmbedPress's "See it in action", an Essential | |
| 200 | + * Addons lightbox whose Magnific popup opened empty). The template | |
| 201 | + * iframe already carries loading="lazy" from the pass above, and a | |
| 202 | + * hidden lazy iframe never loads until shown — so skipping the facade | |
| 203 | + * here costs nothing on page load. | |
| 204 | + */ | |
| 205 | + private const POPUP_TEMPLATE_CLASSES = array( | |
| 206 | + 'eael-lightbox-popup-window', // Essential Addons lightbox template. | |
| 207 | + 'mfp-hide', // Magnific Popup inline template. | |
| 208 | + 'lity-hide', // Lity inline template. | |
| 209 | + ); | |
| 210 | + | |
| 211 | + /** | |
| 212 | + * A facade pass that leaves popup-template containers alone. | |
| 213 | + * | |
| 214 | + * Same PCRE-bail contract as apply_pass(). Ranges are byte spans in | |
| 215 | + * $html; PREG_OFFSET_CAPTURE offsets refer to the original subject, so | |
| 216 | + * earlier replacements never shift the comparison. | |
| 217 | + * | |
| 218 | + * @param callable $callback Rewrite callback taking plain string matches. | |
| 219 | + */ | |
| 220 | + private static function apply_facade_pass( string $html, string $pattern, callable $callback ): string { | |
| 221 | + $ranges = self::popup_template_ranges( $html ); | |
| 222 | + if ( empty( $ranges ) ) { | |
| 223 | + return self::apply_pass( $html, $pattern, $callback ); | |
| 224 | + } | |
| 225 | + | |
| 226 | + $result = preg_replace_callback( | |
| 227 | + $pattern, | |
| 228 | + static function ( array $m ) use ( $callback, $ranges ): string { | |
| 229 | + $offset = (int) $m[0][1]; | |
| 230 | + foreach ( $ranges as $range ) { | |
| 231 | + if ( $offset >= $range[0] && $offset < $range[1] ) { | |
| 232 | + return (string) $m[0][0]; // Inside a template: untouched. | |
| 233 | + } | |
| 234 | + } | |
| 235 | + return (string) call_user_func( | |
| 236 | + $callback, | |
| 237 | + array_map( static fn( $group ): string => (string) $group[0], $m ) | |
| 238 | + ); | |
| 239 | + }, | |
| 240 | + $html, | |
| 241 | + -1, | |
| 242 | + $count, | |
| 243 | + PREG_OFFSET_CAPTURE | |
| 244 | + ); | |
| 245 | + | |
| 246 | + return is_string( $result ) ? $result : $html; | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * Byte spans of every popup-template container in $html. | |
| 251 | + * | |
| 252 | + * The span is found by counting the container's own tag name to its | |
| 253 | + * balancing close — templates are plain nested divs, so a same-tag | |
| 254 | + * depth count is enough; a container whose close is never found is | |
| 255 | + * dropped rather than guessed at (its iframes stay facade-eligible, | |
| 256 | + * the pre-fix behavior). | |
| 257 | + * | |
| 258 | + * @return array<int, array{0:int, 1:int}> | |
| 259 | + */ | |
| 260 | + private static function popup_template_ranges( string $html ): array { | |
| 261 | + /** | |
| 262 | + * Filter the class names marking a hidden popup/lightbox template | |
| 263 | + * whose contents the video facade must leave alone. | |
| 264 | + * | |
| 265 | + * @param string[] $classes | |
| 266 | + */ | |
| 267 | + $classes = (array) apply_filters( 'xspeed_video_facade_popup_classes', self::POPUP_TEMPLATE_CLASSES ); | |
| 268 | + $classes = array_values( | |
| 269 | + array_filter( | |
| 270 | + array_map( 'strval', $classes ), | |
| 271 | + static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c ) | |
| 272 | + ) | |
| 273 | + ); | |
| 274 | + if ( empty( $classes ) ) { | |
| 275 | + return array(); | |
| 276 | + } | |
| 277 | + | |
| 278 | + $pattern = '#<(div|section|span|aside)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:' | |
| 279 | + . implode( '|', array_map( 'preg_quote', $classes ) ) | |
| 280 | + . ')(?![A-Za-z0-9_-])[^"\']*\2[^>]*>#i'; | |
| 281 | + if ( ! preg_match_all( $pattern, $html, $m, PREG_OFFSET_CAPTURE ) ) { | |
| 282 | + return array(); | |
| 283 | + } | |
| 284 | + | |
| 285 | + $ranges = array(); | |
| 286 | + foreach ( $m[0] as $i => $hit ) { | |
| 287 | + $start = (int) $hit[1]; | |
| 288 | + $end = self::same_tag_end( $html, $start, strtolower( (string) $m[1][ $i ][0] ) ); | |
| 289 | + if ( null !== $end ) { | |
| 290 | + $ranges[] = array( $start, $end ); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + return $ranges; | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * The offset just past the close tag balancing the open tag at $offset, | |
| 298 | + * counting only $tag's own opens and closes. Null when it never closes. | |
| 299 | + */ | |
| 300 | + private static function same_tag_end( string $html, int $offset, string $tag ): ?int { | |
| 301 | + $depth = 0; | |
| 302 | + $cursor = $offset; | |
| 303 | + $re = '#<(/?)' . preg_quote( $tag, '#' ) . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i'; | |
| 304 | + while ( preg_match( $re, $html, $t, PREG_OFFSET_CAPTURE, $cursor ) ) { | |
| 305 | + $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] ); | |
| 306 | + if ( '' === $t[1][0] ) { | |
| 307 | + ++$depth; | |
| 308 | + } elseif ( --$depth <= 0 ) { | |
| 309 | + return $cursor; | |
| 310 | + } | |
| 311 | + } | |
| 312 | + return null; | |
| 313 | + } | |
| 314 | + | |
| 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 | + /** | |
| 148 | 457 | * Run one rewrite pass, keeping the input if PCRE bails. |
| 149 | 458 | * |
| 150 | 459 | * preg_replace_callback() returns null when it hits the backtrack or |
| 151 | 460 | * recursion limit — on a large page that would otherwise blank the |
| @@ -185,8 +494,10 @@ | ||
| 185 | 494 | || false !== stripos( $tag, 'data-no-lazy' ) |
| 186 | 495 | || self::has_high_fetchpriority( $tag ) |
| 187 | 496 | || self::is_excluded( $tag, $opts ); |
| 188 | 497 | |
| 498 | + $self_hidden = self::tag_is_hidden( $tag, 'img' ); | |
| 499 | + | |
| 189 | 500 | if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) { |
| 190 | 501 | // An EXCLUDED image is one the user marked as above-the-fold (a |
| 191 | 502 | // hero/logo) — the opposite of lazy. WordPress core adds |
| 192 | 503 | // `loading="lazy"` to images by default (since 5.5), so merely |
| @@ -193,24 +504,46 @@ | ||
| 193 | 504 | // *skipping* our lazy pass would leave core's lazy attribute on |
| 194 | 505 | // the LCP hero and tank LCP. Actively make it eager + |
| 195 | 506 | // high-priority so an excluded hero loads immediately. |
| 196 | 507 | $tag = self::set_attr( $tag, 'loading', 'eager' ); |
| 197 | - $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 | + } | |
| 198 | 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 ); | |
| 199 | 526 | } elseif ( ! empty( $opts['lazy_images'] ) ) { |
| 200 | 527 | // Above-the-fold skip: first N images get loading="eager" |
| 201 | 528 | // instead of "lazy" so the LCP image isn't deferred. Only |
| 202 | - // non-excluded images consume the budget. | |
| 203 | - self::$image_counter++; | |
| 204 | - $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ); | |
| 205 | - $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' ); | |
| 206 | - $tag = self::set_attr( $tag, 'decoding', 'async', true ); | |
| 207 | - // The eager hero should also drop any core `loading="lazy"`; the | |
| 208 | - // set_attr above already overrode it. Give the first eager image | |
| 209 | - // high fetch priority so it wins the LCP race. | |
| 210 | - if ( $is_above_fold ) { | |
| 211 | - $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 ) ); | |
| 212 | 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 | + } | |
| 213 | 546 | } |
| 214 | 547 | |
| 215 | 548 | if ( ! empty( $opts['add_missing_dimensions'] ) ) { |
| 216 | 549 | $tag = self::ensure_dimensions( $tag ); |
| @@ -218,8 +551,103 @@ | ||
| 218 | 551 | |
| 219 | 552 | return $tag; |
| 220 | 553 | } |
| 221 | 554 | |
| 555 | + /** Class that holds an element's background back until it nears the viewport. */ | |
| 556 | + public const LAZY_BG_CLASS = 'xspeed-lazy-bg'; | |
| 557 | + | |
| 558 | + private static function rewrite_background( array $m ): string { | |
| 559 | + $tag = $m[0]; | |
| 560 | + if ( false === stripos( $tag, 'url(' ) ) { | |
| 561 | + return $tag; | |
| 562 | + } | |
| 563 | + if ( ! preg_match( '#\sstyle\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $tag, $sm ) ) { | |
| 564 | + return $tag; | |
| 565 | + } | |
| 566 | + $style = html_entity_decode( isset( $sm[2] ) && '' !== $sm[2] ? $sm[2] : $sm[1], ENT_QUOTES ); | |
| 567 | + if ( ! self::has_deferrable_background( $style ) ) { | |
| 568 | + return $tag; | |
| 569 | + } | |
| 570 | + $opts = self::opts(); | |
| 571 | + if ( false !== stripos( $tag, 'data-skip-lazy' ) | |
| 572 | + || false !== stripos( $tag, 'data-no-lazy' ) | |
| 573 | + || self::is_excluded( $tag, $opts ) ) { | |
| 574 | + return $tag; | |
| 575 | + } | |
| 576 | + self::$background_counter++; | |
| 577 | + if ( self::$background_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ) ) { | |
| 578 | + return $tag; | |
| 579 | + } | |
| 580 | + return self::add_class( $tag, self::LAZY_BG_CLASS ); | |
| 581 | + } | |
| 582 | + | |
| 583 | + /** | |
| 584 | + * Whether an inline style sets a background image we can hold back. | |
| 585 | + * | |
| 586 | + * The hold is a stylesheet rule with !important, which beats a normal | |
| 587 | + * inline declaration but loses to an inline !important one; those are | |
| 588 | + * left alone. data: URIs cost no request, so there is nothing to save. | |
| 589 | + */ | |
| 590 | + public static function has_deferrable_background( string $style ): bool { | |
| 591 | + $found = false; | |
| 592 | + foreach ( explode( ';', $style ) as $decl ) { | |
| 593 | + if ( ! preg_match( '#^\s*background(?:-image)?\s*:(.*)$#is', $decl, $dm ) ) { | |
| 594 | + continue; | |
| 595 | + } | |
| 596 | + $value = $dm[1]; | |
| 597 | + if ( false !== stripos( $value, '!important' ) ) { | |
| 598 | + return false; | |
| 599 | + } | |
| 600 | + if ( preg_match( '#url\(\s*[\'"]?(?!data:)[^)\s\'"]#i', $value ) ) { | |
| 601 | + $found = true; | |
| 602 | + } | |
| 603 | + } | |
| 604 | + return $found; | |
| 605 | + } | |
| 606 | + | |
| 607 | + private static function add_class( string $tag, string $class ): string { | |
| 608 | + if ( preg_match( '#\sclass\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>=`]+))#i', $tag, $cm, PREG_OFFSET_CAPTURE ) ) { | |
| 609 | + // An unquoted value (class=hero) is rewritten as a quoted one. | |
| 610 | + // Adding a second class attribute would lose it: browsers keep | |
| 611 | + // the first of two duplicates. | |
| 612 | + if ( isset( $cm[3] ) && -1 !== $cm[3][1] ) { | |
| 613 | + $group = $cm[3]; | |
| 614 | + return substr( $tag, 0, $group[1] ) . '"' . $group[0] . ' ' . $class . '"' . substr( $tag, $group[1] + strlen( $group[0] ) ); | |
| 615 | + } | |
| 616 | + $group = isset( $cm[2] ) && -1 !== $cm[2][1] ? $cm[2] : $cm[1]; | |
| 617 | + $value = trim( $group[0] . ' ' . $class ); | |
| 618 | + return substr( $tag, 0, $group[1] ) . $value . substr( $tag, $group[1] + strlen( $group[0] ) ); | |
| 619 | + } | |
| 620 | + return (string) preg_replace( '#^<([a-z][a-z0-9-]*)#i', '<$1 class="' . $class . '"', $tag, 1 ); | |
| 621 | + } | |
| 622 | + | |
| 623 | + /** | |
| 624 | + * Scoped to a class the script puts on <html>, so without JavaScript the | |
| 625 | + * rule never matches and every background loads normally. | |
| 626 | + */ | |
| 627 | + public static function background_style(): string { | |
| 628 | + return '.xspeed-lazy-bg-js .' . self::LAZY_BG_CLASS . '{background-image:none!important}'; | |
| 629 | + } | |
| 630 | + | |
| 631 | + /** | |
| 632 | + * The MutationObserver is what keeps a background from staying blank: | |
| 633 | + * carousel clones, infinite scroll and builder re-renders insert | |
| 634 | + * elements carrying the class after DOMContentLoaded, and a one-time | |
| 635 | + * scan never saw them. | |
| 636 | + */ | |
| 637 | + public static function background_script(): string { | |
| 638 | + return <<<'JS' | |
| 639 | +(function(d,w){var h=d.documentElement,C='xspeed-lazy-bg';h.className+=' xspeed-lazy-bg-js'; | |
| 640 | +function show(el){el.classList.remove(C);} | |
| 641 | +var io='IntersectionObserver' in w?new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){show(e.target);io.unobserve(e.target);}});},{rootMargin:'300px 0px'}):null; | |
| 642 | +function watch(el){if(io)io.observe(el);else show(el);} | |
| 643 | +function scan(r){if(r.classList&&r.classList.contains(C))watch(r);if(r.querySelectorAll){var l=r.querySelectorAll('.'+C);for(var i=0;i<l.length;i++)watch(l[i]);}} | |
| 644 | +if('MutationObserver' in w)new MutationObserver(function(ms){for(var j=0;j<ms.length;j++){var a=ms[j].addedNodes;for(var i=0;i<a.length;i++)if(a[i].nodeType===1)scan(a[i]);}}).observe(h,{childList:true,subtree:true}); | |
| 645 | +function run(){scan(d);} | |
| 646 | +if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',run);else run();})(document,window); | |
| 647 | +JS; | |
| 648 | + } | |
| 649 | + | |
| 222 | 650 | private static function rewrite_iframe( array $m ): string { |
| 223 | 651 | $tag = $m[0]; |
| 224 | 652 | if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { |
| 225 | 653 | return $tag; |
| @@ -286,11 +714,16 @@ | ||
| 286 | 714 | } |
| 287 | 715 | |
| 288 | 716 | private static function rewrite_video( array $m ): string { |
| 289 | 717 | $tag = $m[0]; |
| 290 | - 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' ) ) { | |
| 291 | 721 | return $tag; |
| 292 | 722 | } |
| 723 | + if ( self::is_excluded( $tag, self::opts() ) ) { | |
| 724 | + return $tag; | |
| 725 | + } | |
| 293 | 726 | /* |
| 294 | 727 | * An autoplaying video is the one case preload="none" cannot help: |
| 295 | 728 | * browsers fetch an autoplay source regardless of preload, because |
| 296 | 729 | * the author asked for it to start on its own. Setting the attribute |
| @@ -348,9 +781,14 @@ | ||
| 348 | 781 | |
| 349 | 782 | if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { |
| 350 | 783 | return $element; |
| 351 | 784 | } |
| 352 | - 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 ) ) { | |
| 353 | 791 | return $element; |
| 354 | 792 | } |
| 355 | 793 | if ( self::is_excluded( $tag, self::opts() ) ) { |
| 356 | 794 | return $element; |
| @@ -413,8 +851,29 @@ | ||
| 413 | 851 | if ( false !== stripos( $tag, 'data-xspeed-src' ) ) { |
| 414 | 852 | return $tag; |
| 415 | 853 | } |
| 416 | 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 | + | |
| 417 | 876 | $deferred = false; |
| 418 | 877 | |
| 419 | 878 | // The element's own src, when it has one. |
| 420 | 879 | if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) { |
| @@ -449,8 +908,37 @@ | ||
| 449 | 908 | return $tag; |
| 450 | 909 | } |
| 451 | 910 | |
| 452 | 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 | + /** | |
| 453 | 941 | * Did this response defer at least one autoplay video? Gates the script |
| 454 | 942 | * so a page with no such video ships no extra bytes. |
| 455 | 943 | * |
| 456 | 944 | * @var bool |
| @@ -503,13 +991,38 @@ | ||
| 503 | 991 | * |
| 504 | 992 | * Dependency-free and tiny, matching Video_Facade::facade_script(). The |
| 505 | 993 | * rootMargin starts the fetch slightly before the element is visible so |
| 506 | 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. | |
| 507 | 1007 | */ |
| 508 | - public static function autoplay_script(): string { | |
| 509 | - return <<<'JS' | |
| 1008 | + public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string { | |
| 1009 | + $script = <<<'JS' | |
| 510 | 1010 | (function(){ |
| 511 | 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 | +} | |
| 512 | 1025 | |
| 513 | 1026 | /* |
| 514 | 1027 | * Intercept the ASSIGNMENT, because observing the DOM is always too late. |
| 515 | 1028 | * |
| @@ -534,8 +1047,11 @@ | ||
| 534 | 1047 | var hold=function(el,val){ |
| 535 | 1048 | if(el.tagName!=='VIDEO')return false; |
| 536 | 1049 | if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through |
| 537 | 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; | |
| 538 | 1054 | el.setAttribute('data-xspeed-src',String(val)); |
| 539 | 1055 | el.setAttribute('data-xspeed-adopted','1'); |
| 540 | 1056 | return true; |
| 541 | 1057 | }; |
| @@ -555,14 +1071,20 @@ | ||
| 555 | 1071 | }; |
| 556 | 1072 | }catch(e){} |
| 557 | 1073 | function go(v){ |
| 558 | 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;} | |
| 559 | 1081 | v.setAttribute('data-xspeed-loaded','1'); |
| 560 | -var s=v.getAttribute('data-xspeed-src'); | |
| 561 | -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;} | |
| 562 | 1084 | if(v.getAttribute('data-xspeed-defer-sources')){ |
| 563 | 1085 | var c=v.querySelectorAll('source[data-xspeed-src]'); |
| 564 | -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;} | |
| 565 | 1087 | v.removeAttribute('data-xspeed-defer-sources'); |
| 566 | 1088 | } |
| 567 | 1089 | |
| 568 | 1090 | if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');} |
| @@ -567,10 +1089,13 @@ | ||
| 567 | 1089 | |
| 568 | 1090 | if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');} |
| 569 | 1091 | v.removeAttribute('preload'); |
| 570 | 1092 | // load() picks up the sources we just restored; without it a <video> |
| 571 | -// that has already failed to resolve a source will not retry. | |
| 572 | -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(); | |
| 573 | 1098 | } |
| 574 | 1099 | // A multi-format <video> carries no src of its own — the <source> children |
| 575 | 1100 | // do, and those sit outside the opening tag PHP rewrote. Strip them here, |
| 576 | 1101 | // as early as this script runs, then restore on intersect like the rest. |
| @@ -599,8 +1124,16 @@ | ||
| 599 | 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])'); |
| 600 | 1125 | for(var i=0;i<a.length;i++){ |
| 601 | 1126 | var v=a[i]; |
| 602 | 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; | |
| 603 | 1136 | var auto=v.hasAttribute('autoplay'); |
| 604 | 1137 | var s=v.getAttribute('src'); |
| 605 | 1138 | if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');} |
| 606 | 1139 | var c=v.querySelectorAll('source[src]'); |
| @@ -611,36 +1144,80 @@ | ||
| 611 | 1144 | // asked for. |
| 612 | 1145 | if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');} |
| 613 | 1146 | v.setAttribute('preload','none'); |
| 614 | 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); | |
| 615 | 1152 | } |
| 616 | 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);} | |
| 617 | 1184 | function scan(){ |
| 618 | 1185 | strip(); |
| 619 | 1186 | adopt(); |
| 620 | 1187 | var v=document.querySelectorAll(S); |
| 621 | -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;} | |
| 622 | -var o=new IntersectionObserver(function(es){ | |
| 623 | -for(var i=0;i<es.length;i++){if(es[i].isIntersecting){go(es[i].target);o.unobserve(es[i].target);}} | |
| 624 | -},{rootMargin:'200px'}); | |
| 625 | -for(var j=0;j<v.length;j++)o.observe(v[j]); | |
| 1188 | +for(var j=0;j<v.length;j++)watch(v[j]); | |
| 626 | 1189 | } |
| 627 | 1190 | if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan); |
| 628 | 1191 | // Players that build their <video> after load (page-builder video blocks) |
| 629 | -// must be caught the INSTANT the element lands. A debounce loses the race: | |
| 630 | -// the browser begins fetching as soon as a src is set, so by the time a | |
| 631 | -// 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 | |
| 632 | 1194 | // cheap (one guarded querySelectorAll), so run it synchronously on every |
| 633 | -// 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) | |
| 634 | 1197 | if(window.MutationObserver){ |
| 635 | -var t; | |
| 1198 | +var t=0; | |
| 636 | 1199 | new MutationObserver(function(){ |
| 637 | 1200 | adopt(); |
| 638 | -clearTimeout(t);t=setTimeout(scan,200); | |
| 1201 | +if(!t)t=setTimeout(function(){t=0;scan();},200); | |
| 639 | 1202 | }).observe(document.documentElement,{childList:true,subtree:true}); |
| 640 | 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 | +}); | |
| 641 | 1209 | })(); |
| 642 | 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 ); | |
| 643 | 1220 | } |
| 644 | 1221 | |
| 645 | 1222 | private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string { |
| 646 | 1223 | // Lookbehind, not `\b`: writing `width` onto a tag carrying |
| @@ -1389,8 +1966,13 @@ | ||
| 1389 | 1966 | */ |
| 1390 | 1967 | public static function reset_state(): void { |
| 1391 | 1968 | self::$opts = null; |
| 1392 | 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; | |
| 1974 | + self::$background_counter = 0; | |
| 1393 | 1975 | self::$src_dims_cache = null; |
| 1394 | 1976 | self::$facade_used = false; |
| 1395 | 1977 | self::$warming = false; |
| 1396 | 1978 | // Both gate whether the autoplay restorer is printed. Left set, one |