PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-video-facade.php

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

275 lines 11.3 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 }
235
236 /**
237 * The click handler, injected once per page that rendered a facade.
238 *
239 * Deliberately tiny and dependency-free: find the clicked facade,
240 * build the iframe it stands for, replace it. `allow` mirrors what
241 * the providers' own embed codes request so autoplay and fullscreen
242 * behave the same as an un-faceted embed.
243 */
244 public static function facade_script(): string {
245 return <<<'JS'
246 document.addEventListener('click',function(e){
247 var b=e.target.closest&&e.target.closest('.xspeed-video-facade');
248 if(!b)return;
249 var n=b.getAttribute('data-xspeed-video-native');
250 if(n){
251 var v=document.createElement('video');
252 v.setAttribute('src',n);
253 var p=b.getAttribute('data-xspeed-poster');if(p)v.setAttribute('poster',p);
254 v.setAttribute('controls','');
255 v.setAttribute('autoplay','');
256 v.setAttribute('playsinline','');
257 v.setAttribute('style','width:100%;aspect-ratio:16/9;background:#000;');
258 var nt=b.getAttribute('aria-label');if(nt)v.setAttribute('title',nt);
259 b.parentNode.replaceChild(v,b);
260 return;
261 }
262 var u=b.getAttribute('data-xspeed-video');if(!u)return;
263 var f=document.createElement('iframe');
264 f.setAttribute('src',u);
265 f.setAttribute('frameborder','0');
266 f.setAttribute('allow','accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture');
267 f.setAttribute('allowfullscreen','');
268 f.setAttribute('style','width:100%;aspect-ratio:16/9;border:0;');
269 var t=b.getAttribute('aria-label');if(t)f.setAttribute('title',t);
270 b.parentNode.replaceChild(f,b);
271 },false);
272 JS;
273 }
274 }
275