| 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 |
* The one CSS rule the facade can't express as an inline style. |
| 175 |
* |
| 176 |
* Gutenberg wraps an embed in |
| 177 |
* `figure.wp-has-aspect-ratio > div.wp-block-embed__wrapper`, gives the |
| 178 |
* wrapper a `::before` with `padding-top:56.25%` to reserve the 16:9 |
| 179 |
* box, and then absolutely positions the iframe on top of it. Our |
| 180 |
* facade is a `<button>`, so core's `… iframe { position:absolute }` |
| 181 |
* rule doesn't reach it: the button flowed BELOW the reserved box and |
| 182 |
* left a block of empty space the height of the placeholder (363px on |
| 183 |
* a 645px-wide content column). Same fix core uses, aimed at the |
| 184 |
* button — and `!important` because it has to beat the element's own |
| 185 |
* inline style, which is the only place the facade can carry its |
| 186 |
* standalone layout. |
| 187 |
*/ |
| 188 |
public static function facade_style(): string { |
| 189 |
return '.wp-has-aspect-ratio .xspeed-video-facade{position:absolute!important;top:0;right:0;bottom:0;left:0;' |
| 190 |
. 'width:100%!important;height:100%!important;aspect-ratio:auto!important}'; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* The click handler, injected once per page that rendered a facade. |
| 195 |
* |
| 196 |
* Deliberately tiny and dependency-free: find the clicked facade, |
| 197 |
* build the iframe it stands for, replace it. `allow` mirrors what |
| 198 |
* the providers' own embed codes request so autoplay and fullscreen |
| 199 |
* behave the same as an un-faceted embed. |
| 200 |
*/ |
| 201 |
public static function facade_script(): string { |
| 202 |
return <<<'JS' |
| 203 |
document.addEventListener('click',function(e){ |
| 204 |
var b=e.target.closest&&e.target.closest('.xspeed-video-facade'); |
| 205 |
if(!b)return; |
| 206 |
var u=b.getAttribute('data-xspeed-video');if(!u)return; |
| 207 |
var f=document.createElement('iframe'); |
| 208 |
f.setAttribute('src',u); |
| 209 |
f.setAttribute('frameborder','0'); |
| 210 |
f.setAttribute('allow','accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture'); |
| 211 |
f.setAttribute('allowfullscreen',''); |
| 212 |
f.setAttribute('style','width:100%;aspect-ratio:16/9;border:0;'); |
| 213 |
var t=b.getAttribute('aria-label');if(t)f.setAttribute('title',t); |
| 214 |
b.parentNode.replaceChild(f,b); |
| 215 |
},false); |
| 216 |
JS; |
| 217 |
} |
| 218 |
} |
| 219 |
|