| @@ -170,8 +170,51 @@ | ||
| 170 | 170 | return $markup; |
| 171 | 171 | } |
| 172 | 172 | |
| 173 | 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 | + /** | |
| 174 | 217 | * The one CSS rule the facade can't express as an inline style. |
| 175 | 218 | * |
| 176 | 219 | * Gutenberg wraps an embed in |
| 177 | 220 | * `figure.wp-has-aspect-ratio > div.wp-block-embed__wrapper`, gives the |
| @@ -186,8 +229,14 @@ | ||
| 186 | 229 | * standalone layout. |
| 187 | 230 | */ |
| 188 | 231 | public static function facade_style(): string { |
| 189 | 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;' | |
| 190 | 239 | . 'width:100%!important;height:100%!important;aspect-ratio:auto!important}'; |
| 191 | 240 | } |
| 192 | 241 | |
| 193 | 242 | /** |
| @@ -202,10 +251,27 @@ | ||
| 202 | 251 | return <<<'JS' |
| 203 | 252 | document.addEventListener('click',function(e){ |
| 204 | 253 | var b=e.target.closest&&e.target.closest('.xspeed-video-facade'); |
| 205 | 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 | +} | |
| 206 | 268 | var u=b.getAttribute('data-xspeed-video');if(!u)return; |
| 207 | 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'); | |
| 208 | 274 | f.setAttribute('src',u); |
| 209 | 275 | f.setAttribute('frameborder','0'); |
| 210 | 276 | f.setAttribute('allow','accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture'); |
| 211 | 277 | f.setAttribute('allowfullscreen',''); |
| @@ -212,7 +278,112 @@ | ||
| 212 | 278 | f.setAttribute('style','width:100%;aspect-ratio:16/9;border:0;'); |
| 213 | 279 | var t=b.getAttribute('aria-label');if(t)f.setAttribute('title',t); |
| 214 | 280 | b.parentNode.replaceChild(f,b); |
| 215 | 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 | +})(); | |
| 216 | 387 | JS; |
| 217 | 388 | } |
| 218 | 389 | } |