Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.60% covered (success)
98.60%
212 / 215
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Inline_Player
98.60% covered (success)
98.60%
212 / 215
84.62% covered (warning)
84.62%
11 / 13
79
0.00% covered (danger)
0.00%
0 / 1
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_player_options
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
12
 get_attributes_from_embed_url
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
11
 get_asset_config
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_assets
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 should_use_facade
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_poster_url
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
12
 is_private
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 get_poster_from_attachment
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
13.05
 facade_css
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
16
 reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 to_bool
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Inline (non-iframe) VideoPress player rendering.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10/**
11 * Renders VideoPress players in the page from one shared player script.
12 *
13 * Every `videopress.com/embed/` iframe downloads and runs its own copy of the
14 * player, so a page pays for it once per video. In inline mode the player
15 * bundle and stylesheet load once from v0.wordpress.com and a small boot
16 * script mounts a player on each placeholder.
17 */
18class Inline_Player {
19
20    const PLAYER_SCRIPT_URL = 'https://v0.wordpress.com/js/videojs/videopress.js';
21    const PLAYER_STYLE_URL  = 'https://v0.wordpress.com/js/videojs/videopress.css';
22
23    // The Jetpack plugin's legacy in-page embed used this handle; keep it so existing dequeue hooks still apply.
24    const PLAYER_HANDLE = 'videopress-videojs';
25    const BOOT_HANDLE   = 'videopress-inline-player';
26
27    const PLACEHOLDER_CLASS = 'jetpack-videopress-player__inline';
28    const FACADE_CLASS      = 'jetpack-videopress-player__facade';
29
30    /**
31     * How many placeholders this request has rendered; the first poster stays eager, later ones lazy-load.
32     *
33     * @var int
34     */
35    private static $rendered = 0;
36
37    /**
38     * Whether the boot script's config has been printed for this request.
39     *
40     * @var bool
41     */
42    private static $config_printed = false;
43
44    /**
45     * Whether embeds rendered by this site should mount an inline player instead of an iframe.
46     *
47     * @return bool
48     */
49    public static function is_enabled() {
50        /**
51         * Filter whether VideoPress videos are embedded with an iframe.
52         *
53         * Return false to render the player directly in the page from one shared
54         * player script. Defaults to the inverse of the site's inline player setting.
55         *
56         * @module videopress
57         *
58         * @since 3.7.0
59         *
60         * @param bool $use_iframe Whether to embed with an iframe.
61         */
62        return ! apply_filters( 'jetpack_videopress_player_use_iframe', ! Data::get_videopress_inline_player_enabled() );
63    }
64
65    /**
66     * Map block or shortcode attributes to player options.
67     *
68     * Accepts the video block's attribute names (`autoplay`, `preload`, ...);
69     * anything missing falls back to the player's defaults.
70     *
71     * @param array $attributes Attributes to map.
72     * @return array Player options, ready to pass to `videopress()`.
73     */
74    public static function get_player_options( array $attributes = array() ) {
75        $attributes = wp_parse_args(
76            $attributes,
77            array(
78                'autoplay'            => false,
79                'controls'            => true,
80                'loop'                => false,
81                'muted'               => false,
82                'playsinline'         => false,
83                'poster'              => '',
84                'preload'             => 'metadata',
85                'seekbarColor'        => '',
86                'seekbarPlayedColor'  => '',
87                'seekbarLoadingColor' => '',
88                'useAverageColor'     => true,
89                'cover'               => true,
90                'hd'                  => false,
91                'at'                  => 0,
92                'defaultLangCode'     => '',
93            )
94        );
95
96        $preload = is_string( $attributes['preload'] ) ? strtolower( $attributes['preload'] ) : 'metadata';
97        if ( ! in_array( $preload, array( 'auto', 'metadata', 'none' ), true ) ) {
98            $preload = 'metadata';
99        }
100        // The site-wide opt-out wins over the embed's own preload attribute.
101        if ( Data::get_videopress_player_preload_disabled() ) {
102            $preload = 'none';
103        }
104
105        $options = array(
106            'autoPlay'        => self::to_bool( $attributes['autoplay'] ),
107            'controls'        => self::to_bool( $attributes['controls'] ),
108            'loop'            => self::to_bool( $attributes['loop'] ),
109            'muted'           => self::to_bool( $attributes['muted'] ),
110            'persistVolume'   => ! self::to_bool( $attributes['muted'] ),
111            'playsinline'     => self::to_bool( $attributes['playsinline'] ),
112            'cover'           => self::to_bool( $attributes['cover'] ),
113            'hd'              => self::to_bool( $attributes['hd'] ),
114            'useAverageColor' => self::to_bool( $attributes['useAverageColor'] ),
115            'preloadContent'  => $preload,
116            // Embed pages default to the current player skin; match them.
117            'chrome'          => 'v2',
118        );
119
120        if ( (int) $attributes['at'] > 0 ) {
121            $options['at'] = (int) $attributes['at'];
122        }
123
124        if ( ! empty( $attributes['poster'] ) && is_string( $attributes['poster'] ) ) {
125            $options['poster'] = esc_url_raw( $attributes['poster'] );
126        }
127
128        if ( ! empty( $attributes['defaultLangCode'] ) && is_string( $attributes['defaultLangCode'] ) ) {
129            $options['defaultLangCode'] = $attributes['defaultLangCode'];
130        }
131
132        $colors = array(
133            'seekbarColor'        => 'seekbarColor',
134            'seekbarPlayedColor'  => 'seekbarPlayedColor',
135            'seekbarLoadingColor' => 'seekbarLoadedColor',
136        );
137        foreach ( $colors as $attribute => $option ) {
138            if ( ! empty( $attributes[ $attribute ] ) && is_string( $attributes[ $attribute ] ) ) {
139                $options[ $option ] = $attributes[ $attribute ];
140            }
141        }
142
143        /**
144         * Filter the options passed to an inline VideoPress player.
145         *
146         * @since 0.50.2
147         *
148         * @param array $options    Player options.
149         * @param array $attributes The block or shortcode attributes they were built from.
150         */
151        return apply_filters( 'jetpack_videopress_inline_player_options', $options, $attributes );
152    }
153
154    /**
155     * Read the player attributes an embed URL carries in its query string.
156     *
157     * Used to render inline players for videopress.com URLs that reach the
158     * page through oEmbed. Only parameters present in the URL are returned.
159     *
160     * @param string $url A videopress.com/v or /embed URL.
161     * @return array Attributes in the shape `get_player_options()` accepts.
162     */
163    public static function get_attributes_from_embed_url( $url ) {
164        $query = wp_parse_url( $url, PHP_URL_QUERY );
165        if ( ! is_string( $query ) || '' === $query ) {
166            return array();
167        }
168
169        $params = array();
170        parse_str( html_entity_decode( $query, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ), $params );
171
172        $booleans = array(
173            'autoPlay'        => 'autoplay',
174            'autoplay'        => 'autoplay',
175            'controls'        => 'controls',
176            'loop'            => 'loop',
177            'muted'           => 'muted',
178            'playsinline'     => 'playsinline',
179            'useAverageColor' => 'useAverageColor',
180            'cover'           => 'cover',
181            'hd'              => 'hd',
182        );
183        $strings  = array(
184            'posterUrl'       => 'poster',
185            'preloadContent'  => 'preload',
186            'sbc'             => 'seekbarColor',
187            'sbpc'            => 'seekbarPlayedColor',
188            'sblc'            => 'seekbarLoadingColor',
189            'defaultLangCode' => 'defaultLangCode',
190        );
191
192        $attributes = array();
193        foreach ( $booleans as $param => $attribute ) {
194            if ( isset( $params[ $param ] ) && is_string( $params[ $param ] ) ) {
195                $attributes[ $attribute ] = self::to_bool( $params[ $param ] );
196            }
197        }
198        foreach ( $strings as $param => $attribute ) {
199            if ( ! empty( $params[ $param ] ) && is_string( $params[ $param ] ) ) {
200                $attributes[ $attribute ] = $params[ $param ];
201            }
202        }
203        if ( isset( $params['at'] ) && (int) $params['at'] > 0 ) {
204            $attributes['at'] = (int) $params['at'];
205        }
206
207        return $attributes;
208    }
209
210    /**
211     * Versioned URLs of the player bundle and its stylesheet, for anything that loads the player itself.
212     *
213     * @return array{script: string, style: string}
214     */
215    public static function get_asset_config() {
216        return array(
217            'script' => add_query_arg( 'ver', Package_Version::PACKAGE_VERSION, self::PLAYER_SCRIPT_URL ),
218            'style'  => add_query_arg( 'ver', Package_Version::PACKAGE_VERSION, self::PLAYER_STYLE_URL ),
219        );
220    }
221
222    /**
223     * Enqueue the boot script and, unless every player on the page sits behind a facade, the shared player assets.
224     *
225     * The boot script never depends on the player handle: behind a facade it fetches the
226     * bundle itself on the first click, from the URLs printed in its config.
227     *
228     * @param bool $defer_player True to leave the player bundle for the boot script to load on demand.
229     */
230    public static function enqueue_assets( $defer_player = false ) {
231        wp_enqueue_script(
232            self::BOOT_HANDLE,
233            plugins_url( '../build/lib/inline-player.js', __FILE__ ),
234            array(),
235            Package_Version::PACKAGE_VERSION,
236            true
237        );
238
239        if ( ! self::$config_printed ) {
240            self::$config_printed = true;
241            wp_add_inline_script(
242                self::BOOT_HANDLE,
243                'window.jetpackVideoPressInlinePlayer = ' . wp_json_encode( self::get_asset_config(), JSON_UNESCAPED_SLASHES ) . ';',
244                'before'
245            );
246        }
247
248        if ( ! $defer_player ) {
249            wp_enqueue_style( self::PLAYER_HANDLE, self::PLAYER_STYLE_URL, array(), Package_Version::PACKAGE_VERSION );
250            wp_enqueue_script( self::PLAYER_HANDLE, self::PLAYER_SCRIPT_URL, array(), Package_Version::PACKAGE_VERSION, true );
251        }
252
253        // Private videos ask the page for a playback token, same as iframes do.
254        Jwt_Token_Bridge::enqueue_jwt_token_bridge();
255    }
256
257    /**
258     * Whether a placeholder should start as a poster facade instead of a mounted player.
259     *
260     * Autoplaying videos need the player at once; everything else can wait for a click.
261     *
262     * @param array $options Player options, see `get_player_options()`.
263     * @return bool
264     */
265    public static function should_use_facade( array $options = array() ) {
266        $use_facade = empty( $options['autoPlay'] );
267
268        /**
269         * Filter whether inline VideoPress players start as a poster facade and load the player on click.
270         *
271         * @since 0.51.0
272         *
273         * @param bool  $use_facade Whether to render the facade.
274         * @param array $options    The player options for this video.
275         */
276        return (bool) apply_filters( 'jetpack_videopress_inline_player_facade', $use_facade, $options );
277    }
278
279    /**
280     * Resolve the poster to show in a facade, without ever exposing a private video's frame.
281     *
282     * Order: the block's own poster attribute, the attachment's VideoPress metadata
283     * ( by `id`, else by GUID ), then the transient-cached video details lookup.
284     *
285     * @param string $guid       Video GUID.
286     * @param array  $attributes Block or shortcode attributes ( `poster`, `id`, `isPrivate`, `privacySetting` ).
287     * @return string|null Poster URL, or null when none is usable.
288     */
289    public static function get_poster_url( $guid, array $attributes = array() ) {
290        $poster = null;
291
292        if ( ! empty( $attributes['poster'] ) && is_string( $attributes['poster'] ) ) {
293            $poster = esc_url_raw( $attributes['poster'] );
294        } elseif ( ! self::is_private( $attributes ) ) {
295            $poster = self::get_poster_from_attachment( $guid, $attributes['id'] ?? 0 );
296
297            if ( null === $poster && function_exists( 'videopress_get_video_details' ) ) {
298                $details = videopress_get_video_details( $guid );
299                if ( is_object( $details ) && empty( $details->is_private ) && ! empty( $details->poster ) && is_string( $details->poster ) ) {
300                    $poster = esc_url_raw( $details->poster );
301                }
302            }
303        }
304
305        /**
306         * Filter the poster shown by an inline VideoPress player's facade.
307         *
308         * @since 0.51.0
309         *
310         * @param string|null $poster     Poster URL, or null for a plain dark facade.
311         * @param string      $guid       Video GUID.
312         * @param array       $attributes The block or shortcode attributes.
313         */
314        $poster = apply_filters( 'jetpack_videopress_inline_player_poster', $poster, $guid, $attributes );
315
316        return is_string( $poster ) && '' !== $poster ? $poster : null;
317    }
318
319    /**
320     * Whether the attributes describe a private video, treating "site default" as the site's own setting.
321     *
322     * @param array $attributes Block attributes.
323     * @return bool
324     */
325    private static function is_private( array $attributes ) {
326        if ( ! empty( $attributes['isPrivate'] ) ) {
327            return true;
328        }
329
330        // A privacy setting of one is private and two follows the site default; anything else is public.
331        $privacy = isset( $attributes['privacySetting'] ) ? (int) $attributes['privacySetting'] : 2;
332        if ( 1 === $privacy ) {
333            return true;
334        }
335
336        return 2 === $privacy && Data::get_videopress_videos_private_for_site();
337    }
338
339    /**
340     * Poster from the local attachment's VideoPress metadata, when the attachment belongs to this GUID.
341     *
342     * @param string $guid          Video GUID.
343     * @param int    $attachment_id Attachment ID from the block, 0 to look the post up by GUID.
344     * @return string|null
345     */
346    private static function get_poster_from_attachment( $guid, $attachment_id = 0 ) {
347        $attachment_id = (int) $attachment_id;
348
349        if ( $attachment_id <= 0 && function_exists( 'videopress_get_post_by_guid' ) ) {
350            $post          = videopress_get_post_by_guid( $guid );
351            $attachment_id = ( $post instanceof \WP_Post ) ? $post->ID : 0;
352        }
353
354        if ( $attachment_id <= 0 ) {
355            return null;
356        }
357
358        $meta       = wp_get_attachment_metadata( $attachment_id );
359        $videopress = is_array( $meta ) && isset( $meta['videopress'] ) && is_array( $meta['videopress'] ) ? $meta['videopress'] : array();
360        $poster     = $videopress['poster'] ?? '';
361        $meta_guid  = $videopress['guid'] ?? '';
362
363        if ( ! is_string( $poster ) || '' === $poster ) {
364            return null;
365        }
366
367        // A block can point at another video than its attachment does; trust the GUID.
368        if ( is_string( $meta_guid ) && '' !== $meta_guid && $meta_guid !== $guid ) {
369            return null;
370        }
371
372        return esc_url_raw( $poster );
373    }
374
375    /**
376     * Stylesheet for the facade, printed inline so no extra request stands between the HTML and the poster.
377     *
378     * The play button, pre-play scrim and loading spinner copy the player's own chrome, so
379     * nothing visibly changes when the player takes the facade's place.
380     *
381     * @return string CSS.
382     */
383    private static function facade_css() {
384        $p = '.' . self::PLACEHOLDER_CLASS;
385        $f = '.' . self::FACADE_CLASS;
386        return $p . '.is-facade{background:#000;container-type:inline-size;--jetpack-videopress-play-size:min(max(calc(100vw / 8),60px),90px)}'
387            . $f . '{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0;background:transparent;cursor:pointer;display:block;line-height:0}'
388            . $f . '-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}'
389            . $f . '-scrim{position:absolute;inset:0;pointer-events:none;background:radial-gradient(50% 50% at 50% 50%,rgba(0,0,0,.14) 0%,rgba(0,0,0,.3) 100%)}'
390            . $f . '-play{position:absolute;top:50%;left:50%;width:var(--jetpack-videopress-play-size);height:var(--jetpack-videopress-play-size);transform:translate(-50%,-50%);display:block;transition:transform .25s cubic-bezier(.4,0,.6,1) .04s;will-change:transform}'
391            . $f . '-play svg{display:block;width:100%;height:100%;fill:#fff}'
392            . $p . '.is-facade:hover ' . $f . '-play{transform:translate(-50%,-50%) scale(1.08)}'
393            . $f . ':focus-visible{outline:2px solid #fff;outline-offset:-4px}'
394            . $f . '-spinner{position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;pointer-events:none}'
395            . $f . '-spinner span{display:flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:999px;background:rgba(18,18,18,.55);-webkit-backdrop-filter:blur(12px) saturate(1.2);backdrop-filter:blur(12px) saturate(1.2);box-shadow:0 4px 16px rgba(0,0,0,.35)}'
396            . $f . '-spinner span::after{content:"";box-sizing:border-box;width:24px;height:24px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:jetpack-videopress-spin 750ms linear infinite}'
397            . $p . '.is-loading ' . $f . '-play{display:none}'
398            . $p . '.is-loading ' . $f . '-spinner{display:flex}'
399            . '@keyframes jetpack-videopress-spin{to{transform:rotate(360deg)}}'
400            . '@container (max-width:250px){' . $f . '-spinner span{width:40px;height:40px}}'
401            . '@media screen and (max-width:200px){' . $p . '.is-facade{--jetpack-videopress-play-size:40px}' . $f . '-play{opacity:.75}}'
402            . '@media (prefers-reduced-motion:reduce){' . $f . '-play{transition:none}}';
403    }
404
405    /**
406     * Render the placeholder the boot script mounts a player on.
407     *
408     * @param string     $guid    Video GUID.
409     * @param array      $options Player options, see `get_player_options()`.
410     * @param float|null $ratio   Height as a percentage of width (the block's `videoRatio`); 16:9 when unknown.
411     * @param array      $args    Optional `poster` ( URL ), `title` ( for the play button's label ) and `facade` ( bool, default `should_use_facade()` ).
412     * @return string Placeholder markup, or an empty string for an invalid GUID.
413     */
414    public static function render( $guid, array $options = array(), $ratio = null, array $args = array() ) {
415        if ( ! is_string( $guid ) || ! ctype_alnum( $guid ) ) {
416            return '';
417        }
418
419        $facade = isset( $args['facade'] ) ? (bool) $args['facade'] : self::should_use_facade( $options );
420
421        self::enqueue_assets( $facade );
422
423        $ratio = is_numeric( $ratio ) && (float) $ratio > 0 ? (float) $ratio : 56.25;
424        $style = sprintf(
425            'position:relative;width:100%%;aspect-ratio:100 / %s;',
426            rtrim( rtrim( number_format( $ratio, 4, '.', '' ), '0' ), '.' )
427        );
428
429        $inner = '';
430        if ( $facade ) {
431            wp_register_style( self::BOOT_HANDLE, false, array(), Package_Version::PACKAGE_VERSION );
432            wp_enqueue_style( self::BOOT_HANDLE );
433            if ( 0 === self::$rendered ) {
434                wp_add_inline_style( self::BOOT_HANDLE, self::facade_css() );
435            }
436
437            $title = isset( $args['title'] ) && is_string( $args['title'] ) ? trim( $args['title'] ) : '';
438            $label = '' !== $title
439                /* translators: %s is the video title */
440                ? sprintf( __( 'Play video: %s', 'jetpack-videopress-pkg' ), $title )
441                : __( 'Play video', 'jetpack-videopress-pkg' );
442
443            $poster = '';
444            if ( ! empty( $args['poster'] ) && is_string( $args['poster'] ) ) {
445                // The first poster on the page is a likely LCP candidate; later ones can wait for the viewport.
446                $poster = sprintf(
447                    '<img class="%1$s-poster" src="%2$s" alt="" decoding="async"%3$s>',
448                    esc_attr( self::FACADE_CLASS ),
449                    esc_url( $args['poster'] ),
450                    self::$rendered > 0 ? ' loading="lazy"' : ' fetchpriority="high"'
451                );
452            }
453
454            // The glyph is the player's own large play icon.
455            $inner = sprintf(
456                '<button type="button" class="%1$s" aria-label="%2$s">%3$s<span class="%1$s-scrim" aria-hidden="true"></span><span class="%1$s-play" aria-hidden="true"><svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M6.25725 1.075C6.10279 0.977449 5.91041 0.974889 5.75365 1.06832C5.59689 1.16174 5.5 1.3367 5.5 1.52632V20.4737C5.5 20.6633 5.59689 20.8383 5.75365 20.9317C5.91041 21.0251 6.10279 21.0226 6.25725 20.925L21.2573 11.4513C21.4079 11.3562 21.5 11.1849 21.5 11C21.5 10.8151 21.4079 10.6438 21.2573 10.5487L6.25725 1.075Z"/></svg></span></button>'
457                . '<span class="%1$s-spinner" role="status" aria-live="polite" aria-label="%4$s"><span></span></span>',
458                esc_attr( self::FACADE_CLASS ),
459                esc_attr( $label ),
460                $poster,
461                esc_attr__( 'Loading…', 'jetpack-videopress-pkg' )
462            );
463        }
464
465        ++self::$rendered;
466
467        return sprintf(
468            '<div class="%1$s%5$s" data-videopress-guid="%2$s" data-videopress-options="%3$s"%6$s style="%4$s">%7$s</div>',
469            esc_attr( self::PLACEHOLDER_CLASS ),
470            esc_attr( $guid ),
471            esc_attr( wp_json_encode( (object) $options, JSON_UNESCAPED_SLASHES ) ),
472            esc_attr( $style ),
473            $facade ? ' is-facade' : '',
474            $facade ? ' data-videopress-facade="1"' : '',
475            $inner
476        );
477    }
478
479    /**
480     * Forget per-request state ( tests ).
481     */
482    public static function reset() {
483        self::$rendered       = 0;
484        self::$config_printed = false;
485    }
486
487    /**
488     * Interpret the boolean spellings that reach us from attributes and query strings.
489     *
490     * @param mixed $value Raw value.
491     * @return bool
492     */
493    private static function to_bool( $value ) {
494        if ( is_string( $value ) ) {
495            return ! in_array( strtolower( $value ), array( '', '0', 'false', 'no', 'off' ), true );
496        }
497        return (bool) $value;
498    }
499}