PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
xspeed / includes / class-video-facade.php

class-video-facade.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.6, at includes/class-video-facade.php

390 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Video_Facade — replace embedded-video iframes with a lightweight
4 * placeholder that loads the real player only when the visitor asks.
5 *
6 * Why this exists even though the Lazy module already sets
7 * `loading="lazy"` on iframes: deferring the iframe only delays the cost,
8 * it doesn't remove it. The moment a YouTube embed scrolls into view the
9 * browser fetches ~1MB of player JavaScript across several third-party
10 * connections — on a page with three embeds that is most of the page
11 * weight, and it lands exactly when the visitor is trying to read. A
12 * facade replaces the iframe with a poster image and a play button; the
13 * real embed is injected on click, so a visitor who never plays the
14 * video never pays for the player at all.
15 *
16 * Privacy side effect worth stating plainly: the facade makes FEWER
17 * third-party requests than the embed it replaces, and none at all for
18 * providers whose poster we can't derive without an API call.
19 *
20 * Everything in the parsing half is pure (no WP, no I/O) so provider
21 * detection is unit-testable against the real-world src shapes.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Video_Facade {
33
34 /**
35 * Identify the provider and video id behind an iframe src.
36 *
37 * Handles the shapes that actually appear in the wild: youtube.com
38 * /embed/, youtube-nocookie.com, youtu.be short links, and Vimeo's
39 * player.vimeo.com/video/. Returns null for anything else so an
40 * unknown embed is passed through untouched rather than guessed at.
41 *
42 * @return array{provider:string,id:string}|null
43 */
44 public static function parse_embed( string $src ): ?array {
45 $src = trim( html_entity_decode( $src, ENT_QUOTES ) );
46 if ( '' === $src ) {
47 return null;
48 }
49
50 // Protocol-relative and bare-host srcs still need to match.
51 $probe = preg_replace( '#^//#', 'https://', $src );
52
53 // YouTube: /embed/<id>, youtube-nocookie, and youtu.be/<id>.
54 if ( preg_match(
55 '#^https?://(?:www\.)?(?:youtube(?:-nocookie)?\.com/embed/|youtu\.be/)([A-Za-z0-9_-]{6,20})#i',
56 (string) $probe,
57 $m
58 ) ) {
59 return array(
60 'provider' => 'youtube',
61 'id' => $m[1],
62 );
63 }
64
65 // Vimeo: player.vimeo.com/video/<numeric id>.
66 if ( preg_match(
67 '#^https?://player\.vimeo\.com/video/(\d{6,12})#i',
68 (string) $probe,
69 $m
70 ) ) {
71 return array(
72 'provider' => 'vimeo',
73 'id' => $m[1],
74 );
75 }
76
77 return null;
78 }
79
80 /**
81 * Poster URL for an embed, or '' when we can't derive one without an
82 * extra API round-trip.
83 *
84 * YouTube exposes deterministic thumbnail URLs, so a poster costs one
85 * image request — far less than the player it replaces. Vimeo requires
86 * an oEmbed lookup per video, which would mean a server-side HTTP call
87 * during page render; we decline and render a neutral facade instead.
88 */
89 public static function poster_url( string $provider, string $id ): string {
90 if ( 'youtube' === $provider ) {
91 // `/embed/videoseries?list=…` and `/embed/live_stream?channel=…`
92 // put a keyword where a video id normally goes. Both are
93 // id-shaped enough to pass parse_embed, but neither names a
94 // video, so the thumbnail URL built from them 404s — a broken
95 // request on every page view. The facade still renders (a
96 // playlist loads the same heavy player a single video does);
97 // it just renders without a poster.
98 if ( in_array( strtolower( $id ), array( 'videoseries', 'live_stream' ), true ) ) {
99 return '';
100 }
101
102 // hqdefault exists for every video; maxres does not.
103 return 'https://i.ytimg.com/vi/' . rawurlencode( $id ) . '/hqdefault.jpg';
104 }
105
106 return '';
107 }
108
109 /**
110 * The real player URL to swap in on click — autoplay appended so the
111 * click that revealed the player also starts it (one click, not two).
112 */
113 public static function player_url( string $src ): string {
114 $src = html_entity_decode( $src, ENT_QUOTES );
115 if ( false !== strpos( $src, 'autoplay=' ) ) {
116 return $src;
117 }
118
119 return $src . ( false === strpos( $src, '?' ) ? '?' : '&' ) . 'autoplay=1';
120 }
121
122 /**
123 * Build the facade markup for one parsed embed.
124 *
125 * Contract:
126 * - a <button> (not a div) so it is focusable and keyboard-operable
127 * - width/height/style carried over so layout doesn't shift
128 * - the original iframe preserved inside <noscript> so a JS-less
129 * visitor still gets the video
130 * - the player URL travels in a data attribute; the swap is done by
131 * the inline script in facade_script()
132 *
133 * $original_markup must be the COMPLETE element — `<iframe …></iframe>`,
134 * not just the opening tag. The caller replaces that whole span, so a
135 * fallback missing its closing tag would leave a stray `</iframe>`
136 * outside the <noscript> and break the surrounding markup.
137 *
138 * @param string $original_markup The untouched <iframe …></iframe> element.
139 * @param array{provider:string,id:string} $embed Parsed provider + id.
140 * @param string $src The iframe src.
141 * @param string $title Accessible label for the play button.
142 */
143 public static function render( string $original_markup, array $embed, string $src, string $title = '' ): string {
144 $poster = self::poster_url( $embed['provider'], $embed['id'] );
145 $player = self::player_url( $src );
146
147 $label = '' !== $title
148 ? sprintf(
149 /* translators: %s: video title. */
150 __( 'Play video: %s', 'xspeed' ),
151 $title
152 )
153 : __( 'Play video', 'xspeed' );
154
155 $style = 'position:relative;display:block;width:100%;padding:0;border:0;cursor:pointer;background:#000;aspect-ratio:16/9;';
156 if ( '' !== $poster ) {
157 $style .= 'background-image:url(' . esc_url( $poster ) . ');background-size:cover;background-position:center;';
158 }
159
160 $markup = '<button type="button" class="xspeed-video-facade" data-xspeed-video="' . esc_attr( $player ) . '"';
161 $markup .= ' aria-label="' . esc_attr( $label ) . '" style="' . esc_attr( $style ) . '">';
162 // Play glyph — inline SVG so the facade costs zero extra requests
163 // beyond the poster itself.
164 $markup .= '<span class="xspeed-video-facade__play" aria-hidden="true" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:68px;height:48px;border-radius:14px;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;">';
165 $markup .= '<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff" focusable="false"><path d="M8 5v14l11-7z"/></svg>';
166 $markup .= '</span>';
167 $markup .= '</button>';
168 $markup .= '<noscript>' . $original_markup . '</noscript>';
169
170 return $markup;
171 }
172
173 /**
174 * Build the facade markup for one self-hosted <video>. (#309)
175 *
176 * Same contract as render(): a focusable <button>, the original
177 * element preserved whole inside <noscript>, the swap done by
178 * facade_script(). Differences that matter:
179 *
180 * - the poster comes from the element's own poster attribute, never
181 * derived — the caller has already refused to build a facade
182 * without one, because a blank black box is worse than the video.
183 * - the source URL travels in data-xspeed-video-native, a separate
184 * attribute from the iframe player URL, so the click handler knows
185 * to build a <video controls autoplay> rather than an <iframe>.
186 *
187 * @param string $original_markup The untouched <video …>…</video> element.
188 * @param string $src The video file URL to load on click.
189 * @param string $poster The element's own poster URL.
190 * @param string $title Accessible label for the play button.
191 */
192 public static function render_native( string $original_markup, string $src, string $poster, string $title = '' ): string {
193 $label = '' !== $title
194 ? sprintf(
195 /* translators: %s: video title. */
196 __( 'Play video: %s', 'xspeed' ),
197 $title
198 )
199 : __( 'Play video', 'xspeed' );
200
201 $style = 'position:relative;display:block;width:100%;padding:0;border:0;cursor:pointer;background:#000;aspect-ratio:16/9;';
202 $style .= 'background-image:url(' . esc_url( $poster ) . ');background-size:cover;background-position:center;';
203
204 $markup = '<button type="button" class="xspeed-video-facade" data-xspeed-video-native="' . esc_url( $src ) . '"';
205 $markup .= ' data-xspeed-poster="' . esc_url( $poster ) . '"';
206 $markup .= ' aria-label="' . esc_attr( $label ) . '" style="' . esc_attr( $style ) . '">';
207 $markup .= '<span class="xspeed-video-facade__play" aria-hidden="true" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:68px;height:48px;border-radius:14px;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;">';
208 $markup .= '<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff" focusable="false"><path d="M8 5v14l11-7z"/></svg>';
209 $markup .= '</span>';
210 $markup .= '</button>';
211 $markup .= '<noscript>' . $original_markup . '</noscript>';
212
213 return $markup;
214 }
215
216 /**
217 * The one CSS rule the facade can't express as an inline style.
218 *
219 * Gutenberg wraps an embed in
220 * `figure.wp-has-aspect-ratio > div.wp-block-embed__wrapper`, gives the
221 * wrapper a `::before` with `padding-top:56.25%` to reserve the 16:9
222 * box, and then absolutely positions the iframe on top of it. Our
223 * facade is a `<button>`, so core's `… iframe { position:absolute }`
224 * rule doesn't reach it: the button flowed BELOW the reserved box and
225 * left a block of empty space the height of the placeholder (363px on
226 * a 645px-wide content column). Same fix core uses, aimed at the
227 * button — and `!important` because it has to beat the element's own
228 * inline style, which is the only place the facade can carry its
229 * standalone layout.
230 */
231 public static function facade_style(): string {
232 return '.wp-has-aspect-ratio .xspeed-video-facade{position:absolute!important;top:0;right:0;bottom:0;left:0;'
233 . 'width:100%!important;height:100%!important;aspect-ratio:auto!important}'
234 // Magnific Popup's iframe scaler positions only `iframe` children;
235 // a facade that reaches a popup at runtime (a template class the
236 // server pass doesn't know) would otherwise collapse to nothing
237 // and the modal opens blank. Same absolute-fill treatment.
238 . '.mfp-iframe-scaler .xspeed-video-facade{position:absolute!important;top:0;right:0;bottom:0;left:0;'
239 . 'width:100%!important;height:100%!important;aspect-ratio:auto!important}';
240 }
241
242 /**
243 * The click handler, injected once per page that rendered a facade.
244 *
245 * Deliberately tiny and dependency-free: find the clicked facade,
246 * build the iframe it stands for, replace it. `allow` mirrors what
247 * the providers' own embed codes request so autoplay and fullscreen
248 * behave the same as an un-faceted embed.
249 */
250 public static function facade_script(): string {
251 return <<<'JS'
252 document.addEventListener('click',function(e){
253 var b=e.target.closest&&e.target.closest('.xspeed-video-facade');
254 if(!b)return;
255 var n=b.getAttribute('data-xspeed-video-native');
256 if(n){
257 var v=document.createElement('video');
258 v.setAttribute('src',n);
259 var p=b.getAttribute('data-xspeed-poster');if(p)v.setAttribute('poster',p);
260 v.setAttribute('controls','');
261 v.setAttribute('autoplay','');
262 v.setAttribute('playsinline','');
263 v.setAttribute('style','width:100%;aspect-ratio:16/9;background:#000;');
264 var nt=b.getAttribute('aria-label');if(nt)v.setAttribute('title',nt);
265 b.parentNode.replaceChild(v,b);
266 return;
267 }
268 var u=b.getAttribute('data-xspeed-video');if(!u)return;
269 var f=document.createElement('iframe');
270 // Released BEFORE src: the observer script's interceptor holds any
271 // provider src it sees, and the player the visitor just asked for is
272 // the one iframe that must load immediately.
273 f.setAttribute('data-xspeed-loaded','1');
274 f.setAttribute('src',u);
275 f.setAttribute('frameborder','0');
276 f.setAttribute('allow','accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture');
277 f.setAttribute('allowfullscreen','');
278 f.setAttribute('style','width:100%;aspect-ratio:16/9;border:0;');
279 var t=b.getAttribute('aria-label');if(t)f.setAttribute('title',t);
280 b.parentNode.replaceChild(f,b);
281 },false);
282 JS;
283 }
284
285 /**
286 * The early interceptor for embeds that never appear in the HTML.
287 *
288 * A page-builder video widget builds its YouTube/Vimeo <iframe> from
289 * its own script, so the server pass — which rewrites the response
290 * buffer — never sees an element to replace, and the full player
291 * (3MB+ of JS per embed) downloads on page load anyway. Measured live:
292 * one homepage carried three JS-built embeds and 22MB of YouTube
293 * player resources with the facade "on".
294 *
295 * Same playbook as Lazy_Loader::autoplay_script(), for the same
296 * reason: builders set `src` BEFORE inserting the element, so a
297 * MutationObserver alone is always too late — the fetch starts
298 * off-DOM. So the property setter and setAttribute are wrapped, a
299 * recognised provider src is parked in data-xspeed-held instead of
300 * applied, and the observer only has to dress the inert element as a
301 * facade once it lands in the DOM. The click handler above does the
302 * swap; its data-xspeed-loaded release mark is honoured here so the
303 * player it builds is never re-held.
304 *
305 * The provider patterns mirror parse_embed()/poster_url() and must
306 * stay in step with them — one facade, two capture paths.
307 */
308 public static function observer_script(): string {
309 $label = wp_json_encode( __( 'Play video', 'xspeed' ) );
310
311 return <<<JS
312 (function(){
313 var L={$label};
314 function parse(u){
315 if(!u)return null;
316 u=String(u).replace(/^\/\//,'https://');
317 var m=u.match(/^https?:\/\/(?:www\.)?(?:youtube(?:-nocookie)?\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,20})/i);
318 if(m)return{p:'youtube',id:m[1]};
319 m=u.match(/^https?:\/\/player\.vimeo\.com\/video\/(\d{6,12})/i);
320 if(m)return{p:'vimeo',id:m[1]};
321 return null;
322 }
323 function hold(el,val){
324 if(!el||el.tagName!=='IFRAME')return false;
325 if(el.getAttribute('data-xspeed-loaded')||el.hasAttribute('data-skip-lazy'))return false;
326 if(!parse(val))return false;
327 el.setAttribute('data-xspeed-held',String(val));
328 return true;
329 }
330 try{
331 var IP=window.HTMLIFrameElement&&HTMLIFrameElement.prototype;
332 var SD=IP&&Object.getOwnPropertyDescriptor(IP,'src');
333 if(SD&&SD.set){
334 Object.defineProperty(IP,'src',{configurable:true,enumerable:SD.enumerable,
335 get:function(){return SD.get.call(this);},
336 set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}});
337 }
338 var SA=Element.prototype.setAttribute;
339 Element.prototype.setAttribute=function(n,v){
340 if(n==='src'&&hold(this,v))return;
341 return SA.call(this,n,v);
342 };
343 }catch(e){}
344 function dress(f){
345 if(!f.parentNode)return;
346 var s=f.getAttribute('data-xspeed-held');
347 var e=parse(s);
348 if(!e)return;
349 var u=s+(s.indexOf('autoplay=')>-1?'':(s.indexOf('?')>-1?'&':'?')+'autoplay=1');
350 var b=document.createElement('button');
351 b.type='button';
352 b.className='xspeed-video-facade';
353 b.setAttribute('data-xspeed-video',u);
354 var t=f.getAttribute('title');
355 b.setAttribute('aria-label',t?L+': '+t:L);
356 var st='position:relative;display:block;width:100%;padding:0;border:0;cursor:pointer;background:#000;aspect-ratio:16/9;';
357 if(e.p==='youtube'&&!/^(videoseries|live_stream)$/i.test(e.id))
358 st+='background-image:url(https://i.ytimg.com/vi/'+encodeURIComponent(e.id)+'/hqdefault.jpg);background-size:cover;background-position:center;';
359 b.setAttribute('style',st);
360 b.innerHTML='<span class="xspeed-video-facade__play" aria-hidden="true" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:68px;height:48px;border-radius:14px;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;"><svg width="24" height="24" viewBox="0 0 24 24" fill="#fff" focusable="false"><path d="M8 5v14l11-7z"/></svg></span>';
361 f.parentNode.replaceChild(b,f);
362 }
363 function sweep(root){
364 if(!root||!root.querySelectorAll)return;
365 if(root.tagName==='IFRAME'&&root.getAttribute('data-xspeed-held')){dress(root);return;}
366 var h=root.querySelectorAll('iframe[data-xspeed-held]');
367 for(var i=0;i<h.length;i++)dress(h[i]);
368 // An embed written via innerHTML never passed through the wrapped
369 // setters — its src is live, but the parser has only just created it,
370 // so replacing it here still cancels the load before the player runs.
371 if(root.tagName==='IFRAME'&&!root.getAttribute('data-xspeed-loaded')&&!root.hasAttribute('data-skip-lazy')&&parse(root.getAttribute('src'))){
372 root.setAttribute('data-xspeed-held',root.getAttribute('src'));dress(root);return;
373 }
374 var f=root.querySelectorAll('iframe[src]:not([data-xspeed-loaded]):not([data-skip-lazy])');
375 for(var j=0;j<f.length;j++){
376 if(parse(f[j].getAttribute('src'))){f[j].setAttribute('data-xspeed-held',f[j].getAttribute('src'));dress(f[j]);}
377 }
378 }
379 try{
380 new MutationObserver(function(ms){
381 for(var i=0;i<ms.length;i++)for(var j=0;j<ms[i].addedNodes.length;j++)sweep(ms[i].addedNodes[j]);
382 }).observe(document.documentElement,{childList:true,subtree:true});
383 }catch(e){}
384 if(document.readyState!=='loading')sweep(document);
385 else document.addEventListener('DOMContentLoaded',function(){sweep(document);});
386 })();
387 JS;
388 }
389 }
390