| @@ -714,11 +714,16 @@ | ||
| 714 | 714 | } |
| 715 | 715 | |
| 716 | 716 | private static function rewrite_video( array $m ): string { |
| 717 | 717 | $tag = $m[0]; |
| 718 | - 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' ) ) { | |
| 719 | 721 | return $tag; |
| 720 | 722 | } |
| 723 | + if ( self::is_excluded( $tag, self::opts() ) ) { | |
| 724 | + return $tag; | |
| 725 | + } | |
| 721 | 726 | /* |
| 722 | 727 | * An autoplaying video is the one case preload="none" cannot help: |
| 723 | 728 | * browsers fetch an autoplay source regardless of preload, because |
| 724 | 729 | * the author asked for it to start on its own. Setting the attribute |
| @@ -776,9 +781,14 @@ | ||
| 776 | 781 | |
| 777 | 782 | if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { |
| 778 | 783 | return $element; |
| 779 | 784 | } |
| 780 | - 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 ) ) { | |
| 781 | 791 | return $element; |
| 782 | 792 | } |
| 783 | 793 | if ( self::is_excluded( $tag, self::opts() ) ) { |
| 784 | 794 | return $element; |
| @@ -981,13 +991,38 @@ | ||
| 981 | 991 | * |
| 982 | 992 | * Dependency-free and tiny, matching Video_Facade::facade_script(). The |
| 983 | 993 | * rootMargin starts the fetch slightly before the element is visible so |
| 984 | 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. | |
| 985 | 1007 | */ |
| 986 | - public static function autoplay_script(): string { | |
| 987 | - return <<<'JS' | |
| 1008 | + public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string { | |
| 1009 | + $script = <<<'JS' | |
| 988 | 1010 | (function(){ |
| 989 | 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 | +} | |
| 990 | 1025 | |
| 991 | 1026 | /* |
| 992 | 1027 | * Intercept the ASSIGNMENT, because observing the DOM is always too late. |
| 993 | 1028 | * |
| @@ -1012,8 +1047,11 @@ | ||
| 1012 | 1047 | var hold=function(el,val){ |
| 1013 | 1048 | if(el.tagName!=='VIDEO')return false; |
| 1014 | 1049 | if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through |
| 1015 | 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; | |
| 1016 | 1054 | el.setAttribute('data-xspeed-src',String(val)); |
| 1017 | 1055 | el.setAttribute('data-xspeed-adopted','1'); |
| 1018 | 1056 | return true; |
| 1019 | 1057 | }; |
| @@ -1033,14 +1071,20 @@ | ||
| 1033 | 1071 | }; |
| 1034 | 1072 | }catch(e){} |
| 1035 | 1073 | function go(v){ |
| 1036 | 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;} | |
| 1037 | 1081 | v.setAttribute('data-xspeed-loaded','1'); |
| 1038 | -var s=v.getAttribute('data-xspeed-src'); | |
| 1039 | -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;} | |
| 1040 | 1084 | if(v.getAttribute('data-xspeed-defer-sources')){ |
| 1041 | 1085 | var c=v.querySelectorAll('source[data-xspeed-src]'); |
| 1042 | -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;} | |
| 1043 | 1087 | v.removeAttribute('data-xspeed-defer-sources'); |
| 1044 | 1088 | } |
| 1045 | 1089 | |
| 1046 | 1090 | if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');} |
| @@ -1045,10 +1089,13 @@ | ||
| 1045 | 1089 | |
| 1046 | 1090 | if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');} |
| 1047 | 1091 | v.removeAttribute('preload'); |
| 1048 | 1092 | // load() picks up the sources we just restored; without it a <video> |
| 1049 | -// that has already failed to resolve a source will not retry. | |
| 1050 | -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(); | |
| 1051 | 1098 | } |
| 1052 | 1099 | // A multi-format <video> carries no src of its own — the <source> children |
| 1053 | 1100 | // do, and those sit outside the opening tag PHP rewrote. Strip them here, |
| 1054 | 1101 | // as early as this script runs, then restore on intersect like the rest. |
| @@ -1077,8 +1124,16 @@ | ||
| 1077 | 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])'); |
| 1078 | 1125 | for(var i=0;i<a.length;i++){ |
| 1079 | 1126 | var v=a[i]; |
| 1080 | 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; | |
| 1081 | 1136 | var auto=v.hasAttribute('autoplay'); |
| 1082 | 1137 | var s=v.getAttribute('src'); |
| 1083 | 1138 | if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');} |
| 1084 | 1139 | var c=v.querySelectorAll('source[src]'); |
| @@ -1089,8 +1144,12 @@ | ||
| 1089 | 1144 | // asked for. |
| 1090 | 1145 | if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');} |
| 1091 | 1146 | v.setAttribute('preload','none'); |
| 1092 | 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); | |
| 1093 | 1152 | } |
| 1094 | 1153 | } |
| 1095 | 1154 | // A background video (data-xspeed-wait) that reaches the viewport is parked |
| 1096 | 1155 | // here until the visitor first scrolls, taps, types or moves the mouse, then |
| @@ -1103,34 +1162,62 @@ | ||
| 1103 | 1162 | P=[]; |
| 1104 | 1163 | } |
| 1105 | 1164 | for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true}); |
| 1106 | 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);} | |
| 1107 | 1184 | function scan(){ |
| 1108 | 1185 | strip(); |
| 1109 | 1186 | adopt(); |
| 1110 | 1187 | var v=document.querySelectorAll(S); |
| 1111 | -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)reach(v[i]);return;} | |
| 1112 | -var o=new IntersectionObserver(function(es){ | |
| 1113 | -for(var i=0;i<es.length;i++){if(es[i].isIntersecting){reach(es[i].target);o.unobserve(es[i].target);}} | |
| 1114 | -},{rootMargin:'200px'}); | |
| 1115 | -for(var j=0;j<v.length;j++)o.observe(v[j]); | |
| 1188 | +for(var j=0;j<v.length;j++)watch(v[j]); | |
| 1116 | 1189 | } |
| 1117 | 1190 | if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan); |
| 1118 | 1191 | // Players that build their <video> after load (page-builder video blocks) |
| 1119 | -// must be caught the INSTANT the element lands. A debounce loses the race: | |
| 1120 | -// the browser begins fetching as soon as a src is set, so by the time a | |
| 1121 | -// 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 | |
| 1122 | 1194 | // cheap (one guarded querySelectorAll), so run it synchronously on every |
| 1123 | -// 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) | |
| 1124 | 1197 | if(window.MutationObserver){ |
| 1125 | -var t; | |
| 1198 | +var t=0; | |
| 1126 | 1199 | new MutationObserver(function(){ |
| 1127 | 1200 | adopt(); |
| 1128 | -clearTimeout(t);t=setTimeout(scan,200); | |
| 1201 | +if(!t)t=setTimeout(function(){t=0;scan();},200); | |
| 1129 | 1202 | }).observe(document.documentElement,{childList:true,subtree:true}); |
| 1130 | 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 | +}); | |
| 1131 | 1209 | })(); |
| 1132 | 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 ); | |
| 1133 | 1220 | } |
| 1134 | 1221 | |
| 1135 | 1222 | private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string { |
| 1136 | 1223 | // Lookbehind, not `\b`: writing `width` onto a tag carrying |