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

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

219 lines 8.5 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 * 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