Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.45% covered (warning)
86.45%
657 / 760
62.50% covered (warning)
62.50%
20 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
86.45% covered (warning)
86.45%
657 / 760
62.50% covered (warning)
62.50%
20 / 32
392.20
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 update_init_options
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 should_initialize_admin_ui
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 unconditional_initialization
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
4.01
 should_include_utilities
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 prepare_poster_url_for_inline_style
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 active_initialization
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
3
 register_oembed_providers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 video_enqueue_bridge_when_oembed_present
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_videopress_blocks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 register_videopress_all_playlists_block
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_videopress_video_block
92.74% covered (success)
92.74%
115 / 124
0.00% covered (danger)
0.00%
0 / 1
33.42
 register_videopress_video_block
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 register_videopress_playlist_block
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 register_videopress_latest_videos_playlist_block
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 latest_videos_playlist_query
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 render_videopress_latest_videos_playlist_block
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
16
 render_video_browser_controls
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
12
 classification_query_args
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_video_browser_facets
24.00% covered (danger)
24.00%
6 / 25
0.00% covered (danger)
0.00%
0 / 1
28.51
 render_video_browser_active_filters
7.41% covered (danger)
7.41%
2 / 27
0.00% covered (danger)
0.00%
0 / 1
34.58
 render_video_browser_pagination
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_playlist_entries
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
14
 playlist_embed_url
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 playlist_timecode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 playlist_runtime_label
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 playlist_resolution_label
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render_videopress_playlist_block
100.00% covered (success)
100.00%
219 / 219
100.00% covered (success)
100.00%
1 / 1
66
 enqueue_videopress_iframe_api_script
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 maybe_pre_oembed_inline_player
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_render_oembed_inline_player
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 render_inline_player_for_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * The initializer class for the videopress package
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10/**
11 * Initialized the VideoPress package
12 */
13class Initializer {
14
15    /**
16     * Bounds of the Latest Videos Playlist block's "Number of videos" setting;
17     * the editor control uses the same range.
18     */
19    const LATEST_VIDEOS_PLAYLIST_MIN_COUNT     = 1;
20    const LATEST_VIDEOS_PLAYLIST_MAX_COUNT     = 20;
21    const LATEST_VIDEOS_PLAYLIST_DEFAULT_COUNT = 5;
22    const LATEST_VIDEOS_PLAYLIST_MAX_PAGED     = 48;
23
24    const JETPACK_VIDEOPRESS_IFRAME_API_HANDLER = 'jetpack-videopress-iframe-api';
25
26    /**
27     * Initialization optinos
28     *
29     * @var array
30     */
31    protected static $init_options = array();
32
33    /**
34     * Initializes the VideoPress package
35     *
36     * This method is called by Config::ensure.
37     *
38     * @return void
39     */
40    public static function init() {
41        if ( ! did_action( 'videopress_init' ) ) {
42
43            self::unconditional_initialization();
44
45            if ( Status::is_active() ) {
46                self::active_initialization();
47            } elseif ( self::should_initialize_admin_ui() ) {
48                // Keep "Jetpack > VideoPress" in the menu when the module is not
49                // active, linking to the My Jetpack interstitial to activate it.
50                Admin_UI::init_inactive_menu();
51            }
52        }
53
54        /**
55         * Fires after the VideoPress package is initialized
56         *
57         * @since 0.1.1
58         */
59        do_action( 'videopress_init' );
60    }
61
62    /**
63     * Update the initialization options
64     *
65     * This method is called by the Config class
66     *
67     * @param array $options The initialization options.
68     * @return void
69     */
70    public static function update_init_options( array $options ) {
71        if ( empty( $options['admin_ui'] ) || self::should_initialize_admin_ui() ) { // do not overwrite if already set to true.
72            return;
73        }
74
75        self::$init_options['admin_ui'] = $options['admin_ui'];
76    }
77
78    /**
79     * Checks the initialization options and returns whether the admin_ui should be initialized or not
80     *
81     * @return boolean
82     */
83    public static function should_initialize_admin_ui() {
84        return isset( self::$init_options['admin_ui'] ) && true === self::$init_options['admin_ui'];
85    }
86
87    /**
88     * Initialize VideoPress features that should be initialized whenever VideoPress is present, even if the module is not active
89     *
90     * @return void
91     */
92    private static function unconditional_initialization() {
93        if ( self::should_include_utilities() ) {
94            require_once __DIR__ . '/utility-functions.php';
95        }
96
97        // Set up package version hook.
98        add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
99
100        /*
101         * Keep the videopress_guid attachment meta out of reach of the
102         * user-facing meta write APIs (Custom Fields, XML-RPC set_custom_fields,
103         * the WordPress.com JSON API metadata op, REST). The post <-> guid
104         * mapping is what the VideoPress meta and poster endpoints authorize
105         * against, so a writable guid would let a caller point an object they
106         * can edit at somebody else's video and still pass the edit_post check.
107         *
108         * These auth_* filters are consulted by map_meta_cap() for the
109         * add/edit/delete_post_meta capabilities only, so unlike marking the key
110         * protected they leave is_protected_meta() false and meta_key queries
111         * against the WordPress.com JSON API keep working. VideoPress writes the
112         * mapping itself with update_post_meta(), which does not consult
113         * capabilities, so uploads and transcoding are unaffected.
114         *
115         * The subtype-specific filter covers attachments; the generic one covers
116         * calls made before a subtype can be resolved.
117         */
118        add_filter( 'auth_post_meta_videopress_guid_for_attachment', '__return_false' );
119        add_filter( 'auth_post_meta_videopress_guid', '__return_false' );
120
121        Module_Control::init();
122
123        /*
124         * The WPCOM REST API v2 endpoints only register routes/fields on REST
125         * init, so defer constructing them (and autoloading their classes) until
126         * a REST request is actually served. Registered on both REST init hooks
127         * so the routes remain available in every context they were before, and
128         * guarded so the endpoints are instantiated only once per request.
129         */
130        $register_rest_api_v2_endpoints = static function () {
131            static $registered = false;
132            if ( $registered ) {
133                return;
134            }
135            $registered = true;
136            new WPCOM_REST_API_V2_Endpoint_VideoPress();
137            new WPCOM_REST_API_V2_Endpoint_VideoPress_Caption_Tracks();
138            new WPCOM_REST_API_V2_Attachment_VideoPress_Field();
139            new WPCOM_REST_API_V2_Attachment_VideoPress_Data();
140            new WPCOM_REST_API_V2_Endpoint_VideoPress_Edits();
141        };
142        add_action( 'rest_api_init', $register_rest_api_v2_endpoints, 0 );
143        add_action( 'restapi_theme_init', $register_rest_api_v2_endpoints, 0 );
144
145        if ( is_admin() ) {
146            AJAX::init();
147        } else {
148            require_once __DIR__ . '/class-block-replacement.php';
149            Block_Replacement::init();
150        }
151    }
152
153    /**
154     * This avoids conflicts when running VideoPress plugin with older versions of the Jetpack plugin
155     *
156     * On version 11.3-a.7 utility functions include were removed from the plugin and it is safe to include it from the package
157     *
158     * @return boolean
159     */
160    private static function should_include_utilities() {
161        if ( ! class_exists( 'Jetpack' ) || ! defined( 'JETPACK__VERSION' ) ) {
162            return true;
163        }
164
165        return version_compare( JETPACK__VERSION, '11.3-a.7', '>=' );
166    }
167
168    /**
169     * Prepare a poster URL for a quoted CSS url() inside an HTML attribute.
170     *
171     * @param mixed $poster Poster URL.
172     * @return string Sanitized and HTML-encoded poster URL, or an empty string.
173     */
174    private static function prepare_poster_url_for_inline_style( $poster ) {
175        if ( ! is_string( $poster ) || '' === $poster ) {
176            return '';
177        }
178
179        /*
180         * Decode one layer so ordinarily encoded URLs retain their semantics. Any
181         * remaining entities are encoded again below and stay inert after the HTML
182         * parser performs its single decoding pass.
183         */
184        $poster_url = esc_url_raw( html_entity_decode( $poster, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
185        if ( '' === $poster_url ) {
186            return '';
187        }
188
189        /*
190         * Force existing character references to be encoded. esc_attr() preserves
191         * them, but this value crosses from an HTML attribute into a CSS string.
192         */
193        return htmlspecialchars( $poster_url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true );
194    }
195
196    /**
197     * Initialize VideoPress features that should be initialized only when the module is active
198     *
199     * @return void
200     */
201    private static function active_initialization() {
202        Attachment_Handler::init();
203        Jwt_Token_Bridge::init();
204        Caption_Tracks::init();
205        Initial_State::init();
206        XMLRPC::init();
207        Block_Editor_Content::init();
208        Channel::init();
209        Classification::init();
210        Transcript_Index::init();
211        Playlist_Index::init();
212        Analytics_Dashboard::init();
213
214        /*
215         * These endpoints only add their routes on REST init, so defer calling
216         * init() (and autoloading the endpoint classes) until a REST request is
217         * served. Priority 0 ensures the routes still register before the
218         * default-priority rest_api_init handlers run. Class-name strings are
219         * used so the classes are not autoloaded on non-REST requests.
220         */
221        foreach (
222            array(
223                Uploader_Rest_Endpoints::class,
224                Rest_Controller::class,
225                VideoPress_Rest_Api_V1_Stats::class,
226                VideoPress_Rest_Api_V1_Site::class,
227                VideoPress_Rest_Api_V1_Settings::class,
228                VideoPress_Rest_Api_V1_Features::class,
229            ) as $rest_endpoint
230        ) {
231            add_action( 'rest_api_init', array( $rest_endpoint, 'init' ), 0 );
232        }
233        self::register_oembed_providers();
234
235        // In inline mode a VideoPress URL never needs the oEmbed round trip: skip
236        // the fetch, and swap iframes already cached in post meta for a placeholder.
237        add_filter( 'pre_oembed_result', array( __CLASS__, 'maybe_pre_oembed_inline_player' ), 10, 2 );
238        add_filter( 'embed_oembed_html', array( __CLASS__, 'maybe_render_oembed_inline_player' ), 5, 4 );
239
240        // Enqueuethe VideoPress Iframe API script in the front-end.
241        add_filter( 'embed_oembed_html', array( __CLASS__, 'enqueue_videopress_iframe_api_script' ), 10, 4 );
242
243        if ( self::should_initialize_admin_ui() ) {
244            Admin_UI::init();
245        }
246
247        Divi::init();
248    }
249
250    /**
251     * Explicitly register VideoPress oembed provider for patterns not supported by core
252     *
253     * @return void
254     */
255    public static function register_oembed_providers() {
256        $host = rawurlencode( home_url() );
257        // videopress.com/v is already registered in core.
258        // By explicitly declaring the provider here, we can speed things up by not relying on oEmbed discovery.
259        wp_oembed_add_provider( '#^https?://video.wordpress.com/v/.*#', 'https://public-api.wordpress.com/oembed/?for=' . $host, true );
260        // This is needed as it's not supported in oEmbed discovery.
261        wp_oembed_add_provider( '|^https?://v\.wordpress\.com/([a-zA-Z\d]{8})(.+)?$|i', 'https://public-api.wordpress.com/oembed/?for=' . $host, true ); // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
262
263        add_filter( 'embed_oembed_html', array( __CLASS__, 'video_enqueue_bridge_when_oembed_present' ), 10, 4 );
264    }
265
266    /**
267     * Enqueues VideoPress token bridge when a VideoPress oembed is present on the current page.
268     *
269     * @param string|false $cache   The cached HTML result, stored in post meta.
270     * @param string       $url     The attempted embed URL.
271     * @param array        $attr    An array of shortcode attributes.
272     * @param int          $post_ID Post ID.
273     *
274     * @return string|false
275     */
276    public static function video_enqueue_bridge_when_oembed_present( $cache, $url, $attr, $post_ID = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
277        if ( Utils::is_videopress_url( $url ) ) {
278            Jwt_Token_Bridge::enqueue_jwt_token_bridge();
279        }
280
281        return $cache;
282    }
283
284    /**
285     * Register all VideoPress blocks
286     *
287     * @return void
288     */
289    public static function register_videopress_blocks() {
290        // Register VideoPress Video block.
291        self::register_videopress_video_block();
292
293        // Register Video Playlist block.
294        self::register_videopress_playlist_block();
295
296        // Register Latest Videos Playlist block.
297        self::register_videopress_latest_videos_playlist_block();
298
299        // Register All Playlists block.
300        self::register_videopress_all_playlists_block();
301
302        // Register the playlist block's `source` and, where the channel feature is enabled, the channel blocks.
303        Channel_Blocks::register();
304    }
305
306    /**
307     * Register the All Playlists block, which lists the site's Video Playlist
308     * blocks from the playlist index.
309     *
310     * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
311     *                                   package build output; tests can point it at a fixture.
312     *
313     * @return void
314     */
315    public static function register_videopress_all_playlists_block( $metadata_file = null ) {
316        All_Playlists_Block::register( $metadata_file );
317    }
318
319    /**
320     * VideoPress video block render method
321     *
322     * @global \WP_Embed $wp_embed WordPress embed handler.
323     *
324     * @param array     $block_attributes Block attributes.
325     * @param string    $content          Current block markup.
326     * @param \WP_Block $block            Current block.
327     *
328     * @return string Block markup.
329     */
330    public static function render_videopress_video_block( $block_attributes, $content, $block ) {
331        global $wp_embed;
332
333        // Pre-build and cache the GUID list for this post to optimize authorization checks,
334        // and record the GUID actually being rendered: by render time WordPress has expanded
335        // synced patterns, templates, and template parts, so this covers embedding contexts
336        // the static content scan cannot see.
337        $post_id = $block->context['postId'] ?? get_the_ID();
338        if ( ! empty( $post_id ) && isset( $block_attributes['guid'] ) && is_string( $block_attributes['guid'] ) ) {
339            Access_Control::ensure_post_guids_cached( absint( $post_id ), $block_attributes['guid'] );
340        } elseif ( ! empty( $post_id ) ) {
341            Access_Control::build_and_cache_post_guids( absint( $post_id ) );
342        }
343
344        // CSS classes.
345        $align        = $block_attributes['align'] ?? null;
346        $align_class  = $align ? ' align' . $align : '';
347        $custom_class = isset( $block_attributes['className'] ) ? ' ' . $block_attributes['className'] : '';
348        $classes      = 'wp-block-jetpack-videopress jetpack-videopress-player' . $custom_class . $align_class;
349
350        // Inline style.
351        $style     = '';
352        $max_width = isset( $block_attributes['maxWidth'] ) && is_string( $block_attributes['maxWidth'] )
353            ? trim( $block_attributes['maxWidth'] )
354            : '';
355
356        // maxWidth is rendered into an inline style. Accept only a plain CSS length
357        // or percentage so the value stays a single, well-formed declaration;
358        // anything else is dropped and the block renders at full width (as with the
359        // "100%" default).
360        if ( '' !== $max_width && '100%' !== $max_width
361            && preg_match( '/^\d+(\.\d+)?(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/i', $max_width )
362        ) {
363            $style    = sprintf( 'max-width: %s;', $max_width );
364            $classes .= ' wp-block-jetpack-videopress--has-max-width';
365        }
366
367        /*
368         * <figcaption /> element
369         * Caption is stored into the block attributes,
370         * but also it was stored into the <figcaption /> element,
371         * meaning that it could be stored in two different places.
372         */
373        $figcaption = '';
374
375        // Caption from block attributes.
376        $caption = $block_attributes['caption'] ?? null;
377
378        /*
379         * If the caption is not stored into the block attributes,
380         * try to get it from the <figcaption /> element.
381         */
382        if ( null === $caption ) {
383            preg_match( '/<figcaption>(.*?)<\/figcaption>/', $content, $matches );
384            $caption = $matches[1] ?? null;
385        }
386
387        // If we have a caption, create the <figcaption /> element.
388        if ( null !== $caption ) {
389            $figcaption = sprintf( '<figcaption>%s</figcaption>', wp_kses_post( $caption ) );
390        }
391
392        // Custom anchor from block content.
393        $id_attribute = '';
394
395        // Try to get the custom anchor from the block attributes.
396        if ( isset( $block_attributes['anchor'] ) && $block_attributes['anchor'] ) {
397            $id_attribute = sprintf( 'id="%s"', esc_attr( $block_attributes['anchor'] ) );
398        } elseif ( preg_match( '/<figure[^>]*id="([^"]+)"/', $content, $matches ) ) {
399            // Otherwise, try to get the custom anchor from the <figure /> element.
400            $id_attribute = sprintf( 'id="%s"', esc_attr( $matches[1] ) );
401        }
402
403        // Preview On Hover data.
404        $is_poh_enabled =
405            isset( $block_attributes['posterData']['previewOnHover'] ) &&
406            $block_attributes['posterData']['previewOnHover'];
407
408        $autoplay = $block_attributes['autoplay'] ?? false;
409        $controls = $block_attributes['controls'] ?? false;
410        $poster   = $block_attributes['posterData']['url'] ?? null;
411
412        $preview_on_hover = '';
413
414        if ( $is_poh_enabled ) {
415            $preview_on_hover = array(
416                'previewAtTime'       => $block_attributes['posterData']['previewAtTime'],
417                'previewLoopDuration' => $block_attributes['posterData']['previewLoopDuration'],
418                'autoplay'            => $autoplay,
419                'showControls'        => $controls,
420            );
421
422            // Create inline style in case video has a custom poster.
423            $inline_style = '';
424            $poster_url   = self::prepare_poster_url_for_inline_style( $poster );
425            if ( $poster_url ) {
426                // Emit the poster URL as a double-quoted CSS string so it stays
427                // contained within url() and cannot affect the surrounding style.
428                $inline_style = sprintf(
429                    'style="background-image: url(&quot;%s&quot;); background-size: cover; background-position: center center;"',
430                    $poster_url
431                );
432            }
433
434            // Expose the preview on hover data to the client.
435            $preview_on_hover = sprintf(
436                '<div class="jetpack-videopress-player__overlay" %s></div><script type="application/json">%s</script>',
437                $inline_style,
438                wp_json_encode( $preview_on_hover, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
439            );
440
441            // Set `autoplay` and `muted` attributes to the video element.
442            $block_attributes['autoplay'] = true;
443            $block_attributes['muted']    = true;
444        }
445
446        $figure_template = '
447        <figure class="%1$s" style="%2$s" %3$s>
448            %4$s
449            %5$s
450            %6$s
451        </figure>
452        ';
453
454        // VideoPress URL.
455        $guid           = $block_attributes['guid'] ?? null;
456        $videopress_url = Utils::get_video_press_url( $guid, $block_attributes );
457
458        $video_wrapper         = '';
459        $video_wrapper_classes = 'jetpack-videopress-player__wrapper';
460
461        // Preview on hover drives the player through the iframe API, so it keeps the iframe.
462        if ( $guid && ! $is_poh_enabled && Inline_Player::is_enabled() ) {
463            $video_wrapper = sprintf(
464                '<div class="%s">%s</div>',
465                $video_wrapper_classes,
466                Inline_Player::render(
467                    $guid,
468                    Inline_Player::get_player_options( $block_attributes ),
469                    $block_attributes['videoRatio'] ?? null,
470                    array(
471                        'poster' => Inline_Player::get_poster_url( $guid, $block_attributes ),
472                        'title'  => $block_attributes['title'] ?? '',
473                    )
474                )
475            );
476        } elseif ( $videopress_url ) {
477            $videopress_url = wp_kses_post( $videopress_url );
478
479            /*
480             * Provide a fallback iframe for when the oEmbed endpoint fails, e.g.
481             * when the VideoPress backend isn't ready for a freshly uploaded video.
482             * This prevents the published page from showing a bare link.
483             */
484            $fallback = function ( $output, $url ) use ( $videopress_url ) {
485                $decoded_url = html_entity_decode( $videopress_url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
486                if ( $decoded_url !== $url ) {
487                    return $output;
488                }
489
490                return sprintf(
491                    '<iframe title="%1$s" aria-label="%1$s" src="%2$s" width="640" height="360" allowfullscreen data-resize-to-parent="true" allow="clipboard-write; presentation"></iframe>',
492                    esc_attr__( 'VideoPress Video Player', 'jetpack-videopress-pkg' ),
493                    esc_url( preg_replace( '#/v/#', '/embed/', $url, 1 ) )
494                );
495            };
496
497            add_filter( 'embed_maybe_make_link', $fallback, 10, 2 );
498            $oembed_html = apply_filters( 'video_embed_html', $wp_embed->shortcode( array(), $videopress_url ) );
499            remove_filter( 'embed_maybe_make_link', $fallback );
500
501            $video_wrapper = sprintf(
502                '<div class="%s">%s %s</div>',
503                $video_wrapper_classes,
504                $preview_on_hover,
505                $oembed_html
506            );
507
508            /*
509             * Self-heal failed oEmbed cache for VideoPress URLs.
510             *
511             * When the VideoPress backend isn't ready for a freshly uploaded video,
512             * WordPress caches '{{unknown}}' in post meta with a TTL that is too long
513             * for this use case. Clear recent failures so the next page render retries
514             * oEmbed discovery, keeping the fallback iframe above temporary.
515             */
516            $post_id = $block->context['postId'] ?? get_the_ID();
517
518            if ( $post_id ) {
519                $key_suffix   = md5( $videopress_url . serialize( wp_embed_defaults( $videopress_url ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matching WP_Embed cache key format.
520                $oembed_value = get_post_meta( $post_id, '_oembed_' . $key_suffix, true );
521                $oembed_time  = (int) get_post_meta( $post_id, '_oembed_time_' . $key_suffix, true );
522
523                /*
524                 * Only clear the '{{unknown}}' cache entry when it is recent, to avoid
525                 * disabling WordPress's oEmbed backoff for persistent provider failures.
526                 */
527                if (
528                    '{{unknown}}' === $oembed_value
529                    && ( ! $oembed_time || ( time() - $oembed_time ) < MINUTE_IN_SECONDS )
530                ) {
531                    delete_post_meta( $post_id, '_oembed_' . $key_suffix );
532                    delete_post_meta( $post_id, '_oembed_time_' . $key_suffix );
533                }
534            }
535        }
536
537        // Get premium content from block context.
538        $premium_block_plan_id    = isset( $block->context['premium-content/planId'] ) ? intval( $block->context['premium-content/planId'] ) : 0;
539        $is_premium_content_child = isset( $block->context['isPremiumContentChild'] ) ? (bool) $block->context['isPremiumContentChild'] : false;
540        $maybe_premium_script     = '';
541        if ( $is_premium_content_child && is_string( $guid ) ) {
542            Access_Control::instance()->set_guid_subscription( $guid, $premium_block_plan_id );
543            $escaped_guid         = wp_json_encode( $guid, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
544            $script_content       = "if ( ! window.__guidsToPlanIds ) { window.__guidsToPlanIds = {}; }; window.__guidsToPlanIds[$escaped_guid] = $premium_block_plan_id;";
545            $maybe_premium_script = '<script>' . $script_content . '</script>';
546        }
547
548        // $id_attribute, $video_wrapper, $figcaption properly escaped earlier in the code.
549        return sprintf(
550            $figure_template,
551            esc_attr( $classes ),
552            esc_attr( $style ),
553            $id_attribute,
554            $video_wrapper,
555            $figcaption,
556            $maybe_premium_script
557        );
558    }
559
560    /**
561     * Register the VideoPress block editor block,
562     * AKA "VideoPress Block v6".
563     *
564     * @return void
565     */
566    public static function register_videopress_video_block() {
567        /*
568         * If only Jetpack is active, and if the VideoPress module is not active,
569         * we can register the block just to display a placeholder to turn on the module.
570         * That invitation is only useful for admins though.
571         */
572        if (
573            Status::is_jetpack_plugin_without_videopress_module_active()
574            && ! Status::is_standalone_plugin_active()
575            && ! current_user_can( 'jetpack_activate_modules' )
576        ) {
577            return;
578        }
579
580        $videopress_video_metadata_file        = __DIR__ . '/../build/block-editor/blocks/video/block.json';
581        $videopress_video_metadata_file_exists = file_exists( $videopress_video_metadata_file );
582        if ( ! $videopress_video_metadata_file_exists ) {
583            return;
584        }
585
586        $videopress_video_metadata = json_decode(
587            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
588            file_get_contents( $videopress_video_metadata_file )
589        );
590
591        // Pick the block name straight from the block metadata .json file.
592        $videopress_video_block_name = $videopress_video_metadata->name;
593
594        // Is the block already registered?
595        $is_block_registered = \WP_Block_Type_Registry::get_instance()->is_registered( $videopress_video_block_name );
596
597        // Do not register if the block is already registered.
598        if ( $is_block_registered ) {
599            return;
600        }
601
602        $registration = register_block_type(
603            $videopress_video_metadata_file,
604            array(
605                'render_callback'       => array( __CLASS__, 'render_videopress_video_block' ),
606                'render_email_callback' => array( Video_Block_Email_Renderer::class, 'render' ),
607                'uses_context'          => array( 'premium-content/planId', 'isPremiumContentChild', 'selectedPlanId' ),
608            )
609        );
610
611        // Do not enqueue scripts if the block could not be registered.
612        if ( empty( $registration ) || empty( $registration->editor_script_handles ) ) {
613            return;
614        }
615
616        // Extensions use Connection_Initial_State::render_script with script handle as parameter.
617        if ( is_array( $registration->editor_script_handles ) ) {
618            $script_handle = $registration->editor_script_handles[0];
619        } else {
620            $script_handle = $registration->editor_script_handles;
621        }
622
623        // Register and enqueue scripts used by the VideoPress video block.
624        Block_Editor_Extensions::init( $script_handle );
625    }
626
627    /**
628     * Register the Video Playlist block.
629     *
630     * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
631     *                                   package build output; tests can point it at a fixture.
632     *
633     * @return void
634     */
635    public static function register_videopress_playlist_block( $metadata_file = null ) {
636        /*
637         * Unlike the video block, the playlist block has no "activate the module"
638         * placeholder, so it is only registered where VideoPress can play videos.
639         */
640        if (
641            Status::is_jetpack_plugin_without_videopress_module_active()
642            && ! Status::is_standalone_plugin_active()
643        ) {
644            return;
645        }
646
647        if ( null === $metadata_file ) {
648            $metadata_file = __DIR__ . '/../build/block-editor/blocks/playlist/block.json';
649        }
650
651        if ( ! file_exists( $metadata_file ) ) {
652            return;
653        }
654
655        $metadata = json_decode(
656            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
657            file_get_contents( $metadata_file )
658        );
659
660        if ( empty( $metadata->name )
661            || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
662        ) {
663            return;
664        }
665
666        register_block_type(
667            $metadata_file,
668            array(
669                'render_callback'       => array( __CLASS__, 'render_videopress_playlist_block' ),
670                'render_email_callback' => array( Playlist_Block_Email_Renderer::class, 'render' ),
671            )
672        );
673    }
674
675    /**
676     * Register the Latest Videos Playlist block.
677     *
678     * It reuses the Video Playlist block's registered view script and styles, so
679     * it is only registered once that block is. Its inner Video Playlist block is
680     * the editor canvas only: the front end renders the newest videos fresh.
681     *
682     * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
683     *                                   package build output; tests can point it at a fixture.
684     *
685     * @return void
686     */
687    public static function register_videopress_latest_videos_playlist_block( $metadata_file = null ) {
688        if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) {
689            return;
690        }
691
692        if ( null === $metadata_file ) {
693            $metadata_file = __DIR__ . '/../build/block-editor/blocks/latest-videos-playlist/block.json';
694        }
695
696        if ( ! file_exists( $metadata_file ) ) {
697            return;
698        }
699
700        $metadata = json_decode(
701            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
702            file_get_contents( $metadata_file )
703        );
704
705        if ( empty( $metadata->name )
706            || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
707        ) {
708            return;
709        }
710
711        register_block_type(
712            $metadata_file,
713            array(
714                'render_callback'       => array( __CLASS__, 'render_videopress_latest_videos_playlist_block' ),
715                'render_email_callback' => array( Playlist_Block_Email_Renderer::class, 'render_latest_videos' ),
716                'skip_inner_blocks'     => true,
717            )
718        );
719    }
720
721    /**
722     * The sort order, page size and pagination a Latest Videos Playlist block asks for, clamped to what it allows.
723     *
724     * @since 0.61.0
725     *
726     * @param array $block_attributes Block attributes.
727     *
728     * @return array{order: string, count: int, paginated: bool} Order is one of Channel_Data::BROWSE_SORTS.
729     */
730    public static function latest_videos_playlist_query( $block_attributes ) {
731        $order     = isset( $block_attributes['orderBy'] ) && in_array( $block_attributes['orderBy'], Channel_Data::BROWSE_SORTS, true ) ? $block_attributes['orderBy'] : 'newest';
732        $paginated = isset( $block_attributes['pagination'] ) && 'numbered' === $block_attributes['pagination'];
733        $max_count = $paginated ? self::LATEST_VIDEOS_PLAYLIST_MAX_PAGED : self::LATEST_VIDEOS_PLAYLIST_MAX_COUNT;
734        $count     = isset( $block_attributes['count'] ) && is_numeric( $block_attributes['count'] )
735            ? (int) $block_attributes['count']
736            : self::LATEST_VIDEOS_PLAYLIST_DEFAULT_COUNT;
737
738        return array(
739            'order'     => $order,
740            'count'     => max( self::LATEST_VIDEOS_PLAYLIST_MIN_COUNT, min( $max_count, $count ) ),
741            'paginated' => $paginated,
742        );
743    }
744
745    /**
746     * Latest Videos Playlist block render callback: the newest VideoPress videos
747     * on the site, rendered by the Video Playlist block's callback.
748     *
749     * @param array          $block_attributes Block attributes.
750     * @param string         $content          Current block markup, unused: the inner block is never rendered.
751     * @param \WP_Block|null $block            Current block.
752     *
753     * @return string Block markup, or an empty string when the site has no VideoPress videos.
754     */
755    public static function render_videopress_latest_videos_playlist_block( $block_attributes, $content = '', $block = null ) {
756        $query      = self::latest_videos_playlist_query( $block_attributes );
757        $order      = $query['order'];
758        $count      = $query['count'];
759        $paginated  = $query['paginated'];
760        $controls   = ! empty( $block_attributes['showBrowseControls'] );
761        $exclude    = ! empty( $block_attributes['excludeCurrent'] ) ? array_filter( array( Channel::queried_guid() ) ) : array();
762        $show_meta  = ! empty( $block_attributes['showMeta'] );
763        $is_browsed = 'newest' !== $order || $controls || $paginated || $exclude || $show_meta;
764
765        // Dynamic playlists have no title of their own, so no heading either.
766        unset( $block_attributes['playlistTitle'], $block_attributes['showPlaylistTitle'] );
767
768        if ( ! $is_browsed ) {
769            $block_attributes['videos'] = Data::get_latest_videopress_playlist_entries( $count );
770            if ( ! $block_attributes['videos'] ) {
771                return Channel_Blocks::render_empty_state( 'videos', $block_attributes );
772            }
773            return self::render_videopress_playlist_block( $block_attributes, '', $block );
774        }
775
776        // A site without videos gets the empty state, not filters with nothing to filter.
777        if ( ! Channel_Data::videos() ) {
778            return Channel_Blocks::render_empty_state( 'videos', $block_attributes );
779        }
780
781        $state   = Channel_Data::browse_request( $controls, $paginated, $order );
782        $results = Channel_Data::browse( $state, $count, $exclude, $show_meta );
783
784        $block_attributes['videos'] = $show_meta
785            ? $results['entries']
786            : array_map(
787                function ( $entry ) {
788                    return array_diff_key( $entry, array_flip( array( 'views', 'publishedAt' ) ) );
789                },
790                $results['entries']
791            );
792        $before                     = $controls ? self::render_video_browser_controls( $state, $results, $count ) : '';
793        $after                      = $paginated && $results['pages'] > 1 ? self::render_video_browser_pagination( $results['page'], $results['pages'] ) : '';
794        $markup                     = self::render_videopress_playlist_block( $block_attributes, '', $block, $before, $after );
795
796        if ( '' === $markup && $controls ) {
797            // Keep the controls when nothing matches, so the filters can be changed back.
798            $markup = sprintf(
799                '<figure %1$s>%2$s<p class="videopress-video-browser__empty">%3$s</p></figure>',
800                get_block_wrapper_attributes( array( 'class' => 'videopress-playlist is-layout-' . esc_attr( $block_attributes['layout'] ?? 'grid' ) . ' is-empty' ) ),
801                $before,
802                esc_html__( 'No videos match these filters.', 'jetpack-videopress-pkg' ) . ( $state['terms'] ? sprintf( ' <a class="videopress-video-browser__clear" href="%1$s">%2$s</a>', esc_url( remove_query_arg( array_merge( self::classification_query_args(), array( Channel_Data::QUERY_ARG_PAGE ) ) ) ), esc_html__( 'Clear filters', 'jetpack-videopress-pkg' ) ) : '' )
803            );
804        }
805
806        return $markup;
807    }
808
809    /**
810     * The Videos page controls: search, sort and length, submitted as a GET form.
811     *
812     * @param array $state    Browsing state from Channel_Data::browse_request().
813     * @param array $results  Page from Channel_Data::browse().
814     * @param int   $per_page Page size.
815     *
816     * @return string Markup.
817     */
818    private static function render_video_browser_controls( array $state, array $results, $per_page ) {
819        $own_args = array_merge( array( Channel_Data::QUERY_ARG_SEARCH, Channel_Data::QUERY_ARG_SORT, Channel_Data::QUERY_ARG_LENGTH, Channel_Data::QUERY_ARG_PAGE ), self::classification_query_args() );
820        $action   = strtok( remove_query_arg( $own_args ), '?' );
821
822        // A GET form drops the action's query string, so the page's other arguments ride along as fields.
823        $hidden = '';
824        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only echoed back as hidden fields.
825        foreach ( $_GET as $name => $value ) {
826            if ( is_string( $name ) && is_string( $value ) && ! in_array( $name, $own_args, true ) ) {
827                $hidden .= sprintf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( $name ), esc_attr( sanitize_text_field( wp_unslash( $value ) ) ) );
828            }
829        }
830
831        $sorts        = array(
832            'newest'  => __( 'Latest', 'jetpack-videopress-pkg' ),
833            'popular' => __( 'Popular', 'jetpack-videopress-pkg' ),
834            'oldest'  => __( 'Oldest', 'jetpack-videopress-pkg' ),
835        );
836        $sort_buttons = '';
837        foreach ( $sorts as $value => $label ) {
838            $is_current    = $value === $state['sort'];
839            $sort_buttons .= sprintf(
840                '<button type="submit" class="videopress-video-browser__sort%1$s" name="%2$s" value="%3$s" aria-pressed="%4$s">%5$s</button>',
841                $is_current ? ' is-current' : '',
842                esc_attr( Channel_Data::QUERY_ARG_SORT ),
843                esc_attr( 'newest' === $value ? 'latest' : $value ),
844                $is_current ? 'true' : 'false',
845                esc_html( $label )
846            );
847        }
848
849        $lengths = array(
850            'any'   => __( 'Any length', 'jetpack-videopress-pkg' ),
851            'short' => __( 'Under 20 min', 'jetpack-videopress-pkg' ),
852            'mid'   => __( '20 to 45 min', 'jetpack-videopress-pkg' ),
853            'long'  => __( 'Over 45 min', 'jetpack-videopress-pkg' ),
854        );
855        $options = '';
856        foreach ( $lengths as $value => $label ) {
857            $options .= sprintf( '<option value="%1$s"%2$s>%3$s</option>', esc_attr( $value ), selected( $value, $state['length'], false ), esc_html( $label ) );
858        }
859
860        if ( $results['total'] > 0 ) {
861            $summary = sprintf(
862                /* translators: 1: first video shown. 2: last video shown. 3: number of matching videos. */
863                _n( 'Showing %1$s–%2$s of %3$s video', 'Showing %1$s–%2$s of %3$s videos', $results['total'], 'jetpack-videopress-pkg' ),
864                number_format_i18n( $results['start'] + 1 ),
865                number_format_i18n( min( $results['start'] + $per_page, $results['total'] ) ),
866                number_format_i18n( $results['total'] )
867            );
868        } else {
869            $summary = __( '0 videos', 'jetpack-videopress-pkg' );
870        }
871
872        return sprintf(
873            '<form class="videopress-video-browser" method="get" action="%1$s" role="search">%2$s' .
874                '<input type="hidden" name="%3$s" value="%4$s" />' .
875                '<input type="search" class="videopress-video-browser__search" name="%5$s" value="%6$s" placeholder="%7$s" aria-label="%7$s" />' .
876                '<button type="submit" class="videopress-video-browser__submit">%8$s</button>' .
877                '<div class="videopress-video-browser__sorts" role="group" aria-label="%9$s">%10$s</div>' .
878                '<select class="videopress-video-browser__length" name="%11$s" aria-label="%12$s">%13$s</select>' .
879                '%15$s' .
880            '</form>' .
881            '%16$s' .
882            '<p class="videopress-video-browser__summary">%14$s</p>',
883            esc_url( $action ),
884            $hidden,
885            esc_attr( Channel_Data::QUERY_ARG_SORT ),
886            esc_attr( 'newest' === $state['sort'] ? 'latest' : $state['sort'] ),
887            esc_attr( Channel_Data::QUERY_ARG_SEARCH ),
888            esc_attr( $state['search'] ),
889            esc_attr__( 'Search this channel', 'jetpack-videopress-pkg' ),
890            esc_html__( 'Search', 'jetpack-videopress-pkg' ),
891            esc_attr__( 'Sort videos', 'jetpack-videopress-pkg' ),
892            $sort_buttons,
893            esc_attr( Channel_Data::QUERY_ARG_LENGTH ),
894            esc_attr__( 'Video length', 'jetpack-videopress-pkg' ),
895            $options,
896            esc_html( $summary ),
897            self::render_video_browser_facets( $state['terms'] ?? array() ),
898            self::render_video_browser_active_filters( $state['terms'] ?? array() )
899        );
900    }
901
902    /**
903     * The query arguments carrying classification filters.
904     *
905     * @return string[]
906     */
907    private static function classification_query_args() {
908        return array_column( Classification::taxonomies(), 'query_arg' );
909    }
910
911    /**
912     * One disclosure of checkboxes per classification that labels at least one listed video.
913     *
914     * @param array<string, string[]> $selected Selected slugs keyed by taxonomy.
915     *
916     * @return string Markup; empty when nothing is classified.
917     */
918    private static function render_video_browser_facets( array $selected ) {
919        $markup = '';
920        foreach ( Classification::taxonomies() as $taxonomy => $shape ) {
921            $facets = Classification::facets( $taxonomy );
922            if ( ! $facets ) {
923                continue;
924            }
925            $chosen = $selected[ $taxonomy ] ?? array();
926            $boxes  = '';
927            foreach ( $facets as $facet ) {
928                $boxes .= sprintf(
929                    '<label class="videopress-video-browser__facet-option"><input type="checkbox" name="%1$s[]" value="%2$s"%3$s /> <span>%4$s</span> <span class="videopress-video-browser__facet-count">%5$s</span></label>',
930                    esc_attr( $shape['query_arg'] ),
931                    esc_attr( $facet['slug'] ),
932                    checked( in_array( $facet['slug'], $chosen, true ), true, false ),
933                    esc_html( $facet['name'] ),
934                    esc_html( number_format_i18n( $facet['count'] ) )
935                );
936            }
937            $markup .= sprintf(
938                '<details class="videopress-video-browser__facet%1$s"><summary>%2$s</summary><div class="videopress-video-browser__facet-options" role="group" aria-label="%3$s">%4$s<button type="submit" class="videopress-video-browser__apply">%5$s</button></div></details>',
939                $chosen ? ' is-active' : '',
940                esc_html( $chosen ? sprintf( '%1$s (%2$s)', $shape['label'], number_format_i18n( count( $chosen ) ) ) : $shape['label'] ),
941                esc_attr( $shape['label'] ),
942                $boxes,
943                esc_html__( 'Apply', 'jetpack-videopress-pkg' )
944            );
945        }
946        return '' === $markup ? '' : '<div class="videopress-video-browser__facets">' . $markup . '</div>';
947    }
948
949    /**
950     * The selected classification filters as removable chips, plus a clear-all link.
951     *
952     * @param array<string, string[]> $selected Selected slugs keyed by taxonomy.
953     *
954     * @return string Markup; empty when no filter is selected.
955     */
956    private static function render_video_browser_active_filters( array $selected ) {
957        if ( ! $selected ) {
958            return '';
959        }
960        $taxonomies = Classification::taxonomies();
961        $chips      = '';
962        foreach ( $selected as $taxonomy => $slugs ) {
963            $arg = $taxonomies[ $taxonomy ]['query_arg'];
964            foreach ( $slugs as $slug ) {
965                $term = get_term_by( 'slug', $slug, $taxonomy );
966                $rest = array_values( array_diff( $slugs, array( $slug ) ) );
967                $url  = remove_query_arg( array( $arg, Channel_Data::QUERY_ARG_PAGE ) );
968                if ( $rest ) {
969                    $url = add_query_arg( $arg, implode( ',', $rest ), $url );
970                }
971                $name   = $term instanceof \WP_Term ? html_entity_decode( $term->name, ENT_QUOTES ) : $slug;
972                $chips .= sprintf(
973                    '<a class="videopress-video-browser__active-filter" href="%1$s" aria-label="%2$s"><span class="videopress-video-browser__active-filter-label">%3$s:</span> %4$s <span aria-hidden="true">×</span></a>',
974                    esc_url( $url ),
975                    /* translators: 1: a classification such as Topic, 2: a term such as Accessibility. */
976                    esc_attr( sprintf( __( 'Remove filter %1$s: %2$s', 'jetpack-videopress-pkg' ), $taxonomies[ $taxonomy ]['singular'], $name ) ),
977                    esc_html( $taxonomies[ $taxonomy ]['singular'] ),
978                    esc_html( $name )
979                );
980            }
981        }
982        return sprintf(
983            '<div class="videopress-video-browser__active" aria-label="%1$s">%2$s<a class="videopress-video-browser__clear" href="%3$s">%4$s</a></div>',
984            esc_attr__( 'Active filters', 'jetpack-videopress-pkg' ),
985            $chips,
986            esc_url( remove_query_arg( array_merge( self::classification_query_args(), array( Channel_Data::QUERY_ARG_PAGE ) ) ) ),
987            esc_html__( 'Clear filters', 'jetpack-videopress-pkg' )
988        );
989    }
990
991    /**
992     * The Videos page's numbered pagination.
993     *
994     * @param int $current Current page.
995     * @param int $pages   Number of pages.
996     *
997     * @return string Markup.
998     */
999    private static function render_video_browser_pagination( $current, $pages ) {
1000        $url_for = function ( $page ) {
1001            return 1 === $page ? remove_query_arg( Channel_Data::QUERY_ARG_PAGE ) : add_query_arg( Channel_Data::QUERY_ARG_PAGE, $page );
1002        };
1003        $step    = function ( $page, $enabled, $modifier, $label ) use ( $url_for ) {
1004            return $enabled
1005                ? sprintf( '<a class="videopress-video-browser__page videopress-video-browser__page--%1$s" href="%2$s">%3$s</a>', $modifier, esc_url( $url_for( $page ) ), esc_html( $label ) )
1006                : sprintf( '<span class="videopress-video-browser__page videopress-video-browser__page--%1$s is-disabled" aria-disabled="true">%2$s</span>', $modifier, esc_html( $label ) );
1007        };
1008
1009        $links = $step( $current - 1, $current > 1, 'prev', __( '← Previous', 'jetpack-videopress-pkg' ) );
1010        for ( $page = 1; $page <= $pages; $page++ ) {
1011            $links .= $page === $current
1012                ? sprintf( '<span class="videopress-video-browser__page videopress-video-browser__page--number is-current" aria-current="page">%d</span>', $page )
1013                : sprintf( '<a class="videopress-video-browser__page videopress-video-browser__page--number" href="%1$s">%2$d</a>', esc_url( $url_for( $page ) ), $page );
1014        }
1015        $links .= $step( $current + 1, $current < $pages, 'next', __( 'Next →', 'jetpack-videopress-pkg' ) );
1016
1017        return sprintf(
1018            '<nav class="videopress-video-browser__pagination" aria-label="%1$s">%2$s</nav>',
1019            esc_attr__( 'Videos pagination', 'jetpack-videopress-pkg' ),
1020            $links
1021        );
1022    }
1023
1024    /**
1025     * Sanitize the playlist block's videos attribute into rendering-ready entries.
1026     *
1027     * @param mixed $videos Raw attribute value.
1028     *
1029     * @return array Entries with guid, title, durationMs, height and poster keys.
1030     */
1031    public static function sanitize_playlist_entries( $videos ) {
1032        if ( ! is_array( $videos ) ) {
1033            return array();
1034        }
1035
1036        $entries = array();
1037        foreach ( $videos as $video ) {
1038            if ( ! is_array( $video ) || empty( $video['guid'] ) || ! is_string( $video['guid'] ) ) {
1039                continue;
1040            }
1041
1042            // VideoPress GUIDs are 8 alphanumeric characters; drop anything else.
1043            if ( ! preg_match( '/^[a-zA-Z0-9]{8}$/', $video['guid'] ) ) {
1044                continue;
1045            }
1046
1047            /*
1048             * Only the video reference and numeric metadata are stored. Display
1049             * metadata (title, poster) is always live: the view script reads it
1050             * from the video data after the page loads.
1051             */
1052            $entry = array(
1053                'guid'       => $video['guid'],
1054                'durationMs' => isset( $video['durationMs'] ) && is_numeric( $video['durationMs'] ) ? max( 0, (int) $video['durationMs'] ) : 0,
1055                'height'     => isset( $video['height'] ) && is_numeric( $video['height'] ) ? max( 0, (int) $video['height'] ) : 0,
1056            );
1057            // Render-time extras for the meta line; never saved with the block.
1058            foreach ( array( 'views', 'publishedAt' ) as $extra ) {
1059                if ( isset( $video[ $extra ] ) && is_numeric( $video[ $extra ] ) ) {
1060                    $entry[ $extra ] = max( 0, (int) $video[ $extra ] );
1061                }
1062            }
1063            $entries[] = $entry;
1064        }
1065
1066        return $entries;
1067    }
1068
1069    /**
1070     * Build the VideoPress embed URL for a playlist entry.
1071     *
1072     * @param string $guid     Video GUID.
1073     * @param bool   $autoplay Whether the video should start playing once loaded.
1074     * @param bool   $muted    Whether playback should start muted.
1075     *
1076     * @return string Embed URL.
1077     */
1078    private static function playlist_embed_url( $guid, $autoplay, $muted = false ) {
1079        $args = array(
1080            'cover'          => 1,
1081            'preloadContent' => Data::get_videopress_player_preload_disabled() ? 'none' : 'metadata',
1082            'autoPlay'       => $autoplay ? 1 : 0,
1083        );
1084        if ( $muted ) {
1085            $args['muted'] = 1;
1086        }
1087
1088        return add_query_arg(
1089            $args,
1090            'https://videopress.com/embed/' . rawurlencode( $guid )
1091        );
1092    }
1093
1094    /**
1095     * Format a duration in milliseconds as a timecode, m:ss or h:mm:ss.
1096     *
1097     * @param int $duration_ms Duration in milliseconds.
1098     *
1099     * @return string Timecode, or an empty string when the duration is unknown.
1100     */
1101    private static function playlist_timecode( $duration_ms ) {
1102        if ( $duration_ms <= 0 ) {
1103            return '';
1104        }
1105
1106        $total_seconds = (int) round( $duration_ms / 1000 );
1107        $hours         = intdiv( $total_seconds, 3600 );
1108        $minutes       = intdiv( $total_seconds % 3600, 60 );
1109        $seconds       = $total_seconds % 60;
1110
1111        if ( $hours > 0 ) {
1112            return sprintf( '%d:%02d:%02d', $hours, $minutes, $seconds );
1113        }
1114
1115        return sprintf( '%d:%02d', $minutes, $seconds );
1116    }
1117
1118    /**
1119     * Format a duration in milliseconds as a long runtime, e.g. "1 hr 13 min".
1120     *
1121     * @param int $duration_ms Duration in milliseconds.
1122     *
1123     * @return string Runtime label, or an empty string when the duration is unknown.
1124     */
1125    public static function playlist_runtime_label( $duration_ms ) {
1126        if ( $duration_ms <= 0 ) {
1127            return '';
1128        }
1129
1130        $total_minutes = max( 1, (int) round( $duration_ms / 60000 ) );
1131        $hours         = intdiv( $total_minutes, 60 );
1132        $minutes       = $total_minutes % 60;
1133
1134        if ( $hours > 0 && $minutes > 0 ) {
1135            /* translators: 1: number of hours. 2: number of minutes. */
1136            return sprintf( __( '%1$d hr %2$d min', 'jetpack-videopress-pkg' ), $hours, $minutes );
1137        }
1138
1139        if ( $hours > 0 ) {
1140            /* translators: %d: number of hours. */
1141            return sprintf( __( '%d hr', 'jetpack-videopress-pkg' ), $hours );
1142        }
1143
1144        /* translators: %d: number of minutes. */
1145        return sprintf( __( '%d min', 'jetpack-videopress-pkg' ), $minutes );
1146    }
1147
1148    /**
1149     * Map a video's pixel height to a resolution label, e.g. "1080p" or "4K".
1150     *
1151     * @param int $height Video height in pixels.
1152     *
1153     * @return string Resolution label, or an empty string when the height is unknown.
1154     */
1155    private static function playlist_resolution_label( $height ) {
1156        if ( $height <= 0 ) {
1157            return '';
1158        }
1159
1160        return $height >= 2160 ? '4K' : $height . 'p';
1161    }
1162
1163    /**
1164     * Video Playlist block render callback.
1165     *
1166     * @param array          $block_attributes Block attributes.
1167     * @param string         $content          Rendered inner blocks: the title heading, when there is one.
1168     * @param \WP_Block|null $block            Current block.
1169     * @param string         $before           Markup placed above the playlist body, e.g. browse controls.
1170     * @param string         $after            Markup placed below the playlist body, e.g. pagination.
1171     *
1172     * @return string Block markup, or an empty string when the playlist has no playable entries.
1173     */
1174    public static function render_videopress_playlist_block( $block_attributes, $content = '', $block = null, $before = '', $after = '' ) {
1175        $entries = self::sanitize_playlist_entries( $block_attributes['videos'] ?? null );
1176
1177        if ( ! $entries ) {
1178            // A playlist picked from the index shows the empty state while the site has none.
1179            return isset( $block_attributes['source'] ) && 'index' === $block_attributes['source'] && ! Channel::playlists()
1180                ? Channel_Blocks::render_empty_state( 'playlists', $block_attributes )
1181                : '';
1182        }
1183
1184        // Record the rendered GUIDs in the post's cached GUID list so private playlist
1185        // entries pass the playback authorization check, including when the playlist
1186        // sits inside a synced pattern, template, or template part.
1187        $post_id = $block->context['postId'] ?? get_the_ID();
1188        if ( ! empty( $post_id ) ) {
1189            Access_Control::ensure_post_guids_cached( absint( $post_id ), array_column( $entries, 'guid' ) );
1190        }
1191
1192        $enabled = function ( $key, $default_value = true ) use ( $block_attributes ) {
1193            return isset( $block_attributes[ $key ] ) ? (bool) $block_attributes[ $key ] : $default_value;
1194        };
1195
1196        $layout = isset( $block_attributes['layout'] ) && in_array( $block_attributes['layout'], array( 'side-rail', 'grid', 'strip' ), true )
1197            ? $block_attributes['layout']
1198            : 'side-rail';
1199
1200        $show_player = $enabled( 'showPlayer' );
1201        // A hidden player can still be revealed by the first click on an entry.
1202        $reveal_player  = ! $show_player
1203            && isset( $block_attributes['entryClickAction'] )
1204            && 'show-player' === $block_attributes['entryClickAction'];
1205        $has_player     = $show_player || $reveal_player;
1206        $show_thumbnail = $enabled( 'showThumbnail' );
1207        $show_title     = $enabled( 'showTitle' );
1208        $show_res       = $enabled( 'showResolution' );
1209        $show_duration  = $enabled( 'showDuration' );
1210        $show_number    = $enabled( 'showPositionNumber', false );
1211        $show_runtime   = $enabled( 'showTotalRuntime' );
1212        $muted          = $enabled( 'muteByDefault', false );
1213        $list_key       = isset( $block_attributes['videopressListKey'] ) && is_string( $block_attributes['videopressListKey'] ) ? sanitize_key( $block_attributes['videopressListKey'] ) : '';
1214        $current_guid   = Channel::is_enabled() ? Channel::queried_guid() : '';
1215
1216        $classes = array( 'videopress-playlist', 'is-layout-' . $layout );
1217        if ( $enabled( 'darkPlayer', false ) ) {
1218            $classes[] = 'is-dark';
1219        }
1220        if ( ! $show_player ) {
1221            $classes[] = 'hide-player';
1222        }
1223        if ( ! $show_thumbnail ) {
1224            $classes[] = 'hide-thumbnails';
1225        }
1226        if ( ! $show_title ) {
1227            $classes[] = 'hide-titles';
1228        }
1229        if ( ! $show_res ) {
1230            $classes[] = 'hide-resolutions';
1231        }
1232        if ( ! $show_duration ) {
1233            $classes[] = 'hide-durations';
1234        }
1235        if ( ! $show_runtime ) {
1236            $classes[] = 'hide-runtime';
1237        }
1238
1239        // Lets the embed play private videos for authorized viewers.
1240        Jwt_Token_Bridge::enqueue_jwt_token_bridge();
1241
1242        $count    = count( $entries );
1243        $total_ms = 0;
1244        foreach ( $entries as $entry ) {
1245            $total_ms += $entry['durationMs'];
1246        }
1247
1248        $total_timecode = self::playlist_timecode( $total_ms );
1249        /* translators: %d: number of videos in the playlist. */
1250        $count_label = sprintf( _n( '%d video', '%d videos', $count, 'jetpack-videopress-pkg' ), $count );
1251
1252        /*
1253         * Hidden placeholder shown (via the button's is-locked class) when the view
1254         * script cannot authorize a private video's thumbnail for the viewer.
1255         */
1256        $lock_markup = '<span class="videopress-playlist__entry-lock">'
1257            . '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M17 10h-1.2V7.3c0-2.1-1.7-3.8-3.8-3.8-2.1 0-3.8 1.7-3.8 3.8V10H7c-.6 0-1 .4-1 1v8c0 .6.4 1 1 1h10c.6 0 1-.4 1-1v-8c0-.6-.4-1-1-1Zm-2.7 0H9.7V7.3c0-1.3 1-2.3 2.3-2.3 1.3 0 2.3 1 2.3 2.3V10Z"/></svg>'
1258            . '<span class="videopress-playlist__entry-lock-label">' . esc_html__( 'Private video', 'jetpack-videopress-pkg' ) . '</span>'
1259            . '</span>';
1260
1261        $items = '';
1262        foreach ( $entries as $index => $entry ) {
1263            /*
1264             * Positional fallback only: the view script replaces titles (and
1265             * adds posters) with live video data once the page loads.
1266             */
1267            /* translators: %d: position of the video in the playlist. */
1268            $title = sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 );
1269
1270            $timecode   = self::playlist_timecode( $entry['durationMs'] );
1271            $resolution = self::playlist_resolution_label( $entry['height'] );
1272            /* translators: 1: position of the video in the playlist. 2: number of videos in the playlist. */
1273            $position = sprintf( __( '%1$d of %2$d', 'jetpack-videopress-pkg' ), $index + 1, $count );
1274            $details  = implode( ' · ', array_filter( array( $resolution, $timecode ) ) );
1275            $progress = '' !== $total_timecode
1276                /* translators: 1: position of the video in the playlist. 2: number of videos. 3: total playlist timecode. */
1277                ? sprintf( __( '%1$d / %2$d · %3$s total', 'jetpack-videopress-pkg' ), $index + 1, $count, $total_timecode )
1278                /* translators: 1: position of the video in the playlist. 2: number of videos. */
1279                : sprintf( __( '%1$d / %2$d', 'jetpack-videopress-pkg' ), $index + 1, $count );
1280
1281            $number_markup = $show_number
1282                ? sprintf(
1283                    '<span class="videopress-playlist__entry-number">%s</span>',
1284                    esc_html( str_pad( (string) ( $index + 1 ), 2, '0', STR_PAD_LEFT ) )
1285                )
1286                : '';
1287
1288            $time_markup = '' !== $timecode
1289                ? sprintf( '<span class="videopress-playlist__entry-time">%s</span>', esc_html( $timecode ) )
1290                : '';
1291
1292            $resolution_markup = '' !== $resolution
1293                ? sprintf( '<span class="videopress-playlist__entry-resolution">%s</span>', esc_html( $resolution ) )
1294                : '';
1295
1296            $duration_markup = '' !== $timecode
1297                ? sprintf( '<span class="videopress-playlist__entry-duration">%s</span>', esc_html( $timecode ) )
1298                : '';
1299
1300            $stats_label  = Channel_Data::views_and_age( array_intersect_key( $entry, array_flip( array( 'views', 'publishedAt' ) ) ) );
1301            $stats_markup = '' !== $stats_label
1302                ? sprintf( '<span class="videopress-playlist__entry-stats">%s</span>', esc_html( $stats_label ) )
1303                : '';
1304
1305            if ( $has_player ) {
1306                $is_current  = $show_player && 0 === $index;
1307                $entry_open  = sprintf(
1308                    '<button type="button" class="videopress-playlist__select%1$s"%2$s data-guid="%3$s" data-embed-url="%4$s" data-title="%5$s" data-position="%6$s" data-details="%7$s" data-progress="%8$s">',
1309                    $is_current ? ' is-current' : '',
1310                    $is_current ? ' aria-current="true"' : '',
1311                    esc_attr( $entry['guid'] ),
1312                    esc_url( self::playlist_embed_url( $entry['guid'], true, $muted ) ),
1313                    esc_attr( $title ),
1314                    esc_attr( $position ),
1315                    esc_attr( $details ),
1316                    esc_attr( $progress )
1317                );
1318                $entry_close = '</button>';
1319            } else {
1320                // No player to load the video into: the entry opens the video's page instead.
1321                /**
1322                 * Filters where a playlist entry opens when the block has no player.
1323                 *
1324                 * @since 0.55.0
1325                 *
1326                 * @param string $url  The video's page on videopress.com.
1327                 * @param string $guid The video GUID.
1328                 */
1329                $entry_url = apply_filters( 'videopress_playlist_entry_url', 'https://videopress.com/v/' . $entry['guid'], $entry['guid'] );
1330                $on_site   = Channel::is_site_url( $entry_url );
1331                if ( $on_site && '' !== $list_key ) {
1332                    $entry_url = add_query_arg( Channel::LIST_VAR, $list_key, $entry_url );
1333                }
1334                $is_current = '' !== $current_guid && $entry['guid'] === $current_guid;
1335                $entry_open = sprintf(
1336                    '<a class="videopress-playlist__select%1$s" href="%2$s"%3$s%4$s data-guid="%5$s" data-title="%6$s" data-position="%7$s" data-details="%8$s">',
1337                    $is_current ? ' is-current' : '',
1338                    esc_url( $entry_url ),
1339                    // Channel pages stay in the tab; videopress.com opens a new one.
1340                    $on_site ? '' : ' target="_blank" rel="noopener noreferrer"',
1341                    $is_current ? ' aria-current="true"' : '',
1342                    esc_attr( $entry['guid'] ),
1343                    esc_attr( $title ),
1344                    esc_attr( $position ),
1345                    esc_attr( $details )
1346                );
1347                $entry_close = '</a>';
1348            }
1349
1350            $items .= sprintf(
1351                '<li class="videopress-playlist__entry%10$s">%1$s' .
1352                    '%2$s<span class="videopress-playlist__entry-thumb"><span class="videopress-playlist__entry-flag">%3$s</span>%4$s%5$s</span>' .
1353                    '<span class="videopress-playlist__entry-body"><span class="videopress-playlist__entry-title">%6$s</span><span class="videopress-playlist__entry-meta">%7$s%8$s</span>%11$s</span>' .
1354                    '%9$s</li>',
1355                $entry_open,
1356                $number_markup,
1357                esc_html__( 'Playing', 'jetpack-videopress-pkg' ),
1358                $lock_markup,
1359                $time_markup,
1360                esc_html( $title ),
1361                $resolution_markup,
1362                $duration_markup,
1363                $entry_close,
1364                ! $has_player && '' !== $current_guid && $entry['guid'] === $current_guid ? ' is-current' : '',
1365                $stats_markup
1366            );
1367        }
1368
1369        $first          = $entries[0];
1370        $first_title    = __( 'Video 1', 'jetpack-videopress-pkg' );
1371        $runtime_label  = self::playlist_runtime_label( $total_ms );
1372        $runtime_markup = $show_runtime && '' !== $runtime_label
1373            ? sprintf( '<span class="videopress-playlist__runtime">%s</span>', esc_html( $runtime_label ) )
1374            : '';
1375
1376        // Count · runtime meta line, shown by the side-rail layout in the list header.
1377        $list_meta_markup = sprintf(
1378            '<span class="videopress-playlist__list-meta"><span class="videopress-playlist__count">%s</span>%s</span>',
1379            esc_html( $count_label ),
1380            $runtime_markup
1381        );
1382
1383        /*
1384         * The player renders its own title overlay, so the stage carries no
1385         * duplicate now-playing text — only the grid layout's runtime line.
1386         */
1387        $now_markup = $show_runtime && '' !== $runtime_label
1388            ? sprintf(
1389                '<div class="videopress-playlist__now"><span class="videopress-playlist__now-runtime">%s</span></div>',
1390                esc_html( $count_label . ' · ' . $runtime_label )
1391            )
1392            : '';
1393
1394        if ( $show_player ) {
1395            $stage_markup = sprintf(
1396                '<div class="videopress-playlist__stage">' .
1397                    '<div class="videopress-playlist__player"><iframe class="videopress-playlist__iframe" title="%1$s" src="%2$s" allowfullscreen allow="clipboard-write"></iframe></div>%3$s</div>',
1398                esc_attr( $first_title ),
1399                esc_url( self::playlist_embed_url( $first['guid'], false, $muted ) ),
1400                $now_markup
1401            );
1402        } elseif ( $reveal_player ) {
1403            // No src: nothing loads until the view script reveals the stage on a click.
1404            $stage_markup = sprintf(
1405                '<div class="videopress-playlist__stage" hidden>' .
1406                    '<div class="videopress-playlist__player"><iframe class="videopress-playlist__iframe" title="%1$s" allowfullscreen allow="clipboard-write"></iframe></div></div>%2$s',
1407                esc_attr( $first_title ),
1408                $now_markup
1409            );
1410        } else {
1411            // Without a player only the grid layout's runtime line remains of the stage.
1412            $stage_markup = $now_markup;
1413        }
1414
1415        $progress_markup = $has_player && '' !== $total_timecode
1416            ? sprintf(
1417                '<span class="videopress-playlist__list-progress">%s</span>',
1418                /* translators: 1: position of the current video. 2: number of videos. 3: total playlist timecode. */
1419                esc_html( sprintf( __( '%1$d / %2$d · %3$s total', 'jetpack-videopress-pkg' ), 1, $count, $total_timecode ) )
1420            )
1421            : '';
1422
1423        $list_markup = sprintf(
1424            '<div class="videopress-playlist__list">' .
1425                '<div class="videopress-playlist__list-header">' .
1426                '<span class="videopress-playlist__list-label videopress-playlist__list-label--rail">%1$s</span>' .
1427                '<span class="videopress-playlist__list-label videopress-playlist__list-label--strip">%2$s</span>%3$s%4$s</div>' .
1428                '<ol class="videopress-playlist__entries">%5$s</ol></div>',
1429            $show_player
1430                ? esc_html__( 'Up next', 'jetpack-videopress-pkg' )
1431                : esc_html__( 'Playlist', 'jetpack-videopress-pkg' ),
1432            /* translators: %s: number of videos in the playlist, e.g. "5 videos". */
1433            esc_html( sprintf( __( 'Playlist — %s', 'jetpack-videopress-pkg' ), $count_label ) ),
1434            $list_meta_markup,
1435            $progress_markup,
1436            $items
1437        );
1438
1439        /*
1440         * User-selected theme font presets (theme.json slugs) for the
1441         * customizable titles, exposed as CSS custom properties the
1442         * stylesheet reads. Anything but a plain preset slug is dropped.
1443         */
1444        $font_style = '';
1445        foreach ( array(
1446            'entryTitleFontFamily' => '--vpp-entry-title-font',
1447        ) as $font_attribute => $css_variable ) {
1448            if ( ! empty( $block_attributes[ $font_attribute ] )
1449                && is_string( $block_attributes[ $font_attribute ] )
1450                && preg_match( '/^[a-zA-Z0-9-]+$/', $block_attributes[ $font_attribute ] )
1451            ) {
1452                $font_style .= $css_variable . ':var(--wp--preset--font-family--' . $block_attributes[ $font_attribute ] . ');';
1453            }
1454        }
1455
1456        // Looping implies auto-advancing, so it forces the autoplay-next flag on.
1457        $loop_playlist = $enabled( 'loopPlaylist', false );
1458
1459        $wrapper_extra_attributes = array(
1460            'class'              => implode( ' ', $classes ),
1461            'data-autoplay-next' => $enabled( 'autoplayNext', false ) || $loop_playlist ? '1' : '0',
1462            'data-loop'          => $loop_playlist ? '1' : '0',
1463        );
1464        if ( '' !== $font_style ) {
1465            $wrapper_extra_attributes['style'] = $font_style;
1466        }
1467
1468        $wrapper_attributes = get_block_wrapper_attributes( $wrapper_extra_attributes );
1469
1470        // The Heading inner block saved with the post; an empty title renders none.
1471        $playlist_title = isset( $block_attributes['playlistTitle'] ) && is_string( $block_attributes['playlistTitle'] )
1472            ? trim( $block_attributes['playlistTitle'] )
1473            : '';
1474        $content        = is_string( $content ) ? trim( $content ) : '';
1475        $heading_markup = $enabled( 'showPlaylistTitle' ) && '' !== $playlist_title && '' !== $content
1476            ? '<div class="videopress-playlist__heading">' . $content . '</div>'
1477            : '';
1478
1479        return sprintf(
1480            '<figure %1$s>%2$s%5$s<div class="videopress-playlist__body">%3$s%4$s</div>%6$s</figure>',
1481            $wrapper_attributes,
1482            $heading_markup,
1483            $stage_markup,
1484            $list_markup,
1485            $before,
1486            $after
1487        );
1488    }
1489
1490    /**
1491     * Enqueue the VideoPress Iframe API script
1492     * when the URL of oEmbed HTML is a VideoPress URL.
1493     *
1494     * @param string|false $cache   The cached HTML result, stored in post meta.
1495     * @param string       $url     The attempted embed URL.
1496     * @param array        $attr    An array of shortcode attributes.
1497     * @param int          $post_ID Post ID.
1498     *
1499     * @return string|false
1500     */
1501    public static function enqueue_videopress_iframe_api_script( $cache, $url, $attr, $post_ID ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1502        if ( Utils::is_videopress_url( $url ) && ! Inline_Player::is_enabled() ) {
1503            // Enqueue the VideoPress IFrame API in the front-end.
1504            wp_enqueue_script(
1505                self::JETPACK_VIDEOPRESS_IFRAME_API_HANDLER,
1506                'https://s0.wp.com/wp-content/plugins/video/assets/js/videojs/videopress-iframe-api.js',
1507                array(),
1508                gmdate( 'YW' ),
1509                false
1510            );
1511        }
1512
1513        return $cache;
1514    }
1515
1516    /**
1517     * Short-circuit the oEmbed request for VideoPress URLs when the inline player is on.
1518     *
1519     * @param null|string $result The oEmbed result, null to let WordPress fetch it.
1520     * @param string      $url    The URL being embedded.
1521     * @return null|string Inline player markup, or the untouched result.
1522     */
1523    public static function maybe_pre_oembed_inline_player( $result, $url ) {
1524        $inline = self::render_inline_player_for_url( $url );
1525
1526        return null === $inline ? $result : $inline;
1527    }
1528
1529    /**
1530     * Replace a VideoPress oEmbed iframe (fresh or cached) with an inline player when the inline player is on.
1531     *
1532     * @param string|false $cache   The oEmbed HTML.
1533     * @param string       $url     The URL being embedded.
1534     * @param array        $attr    Shortcode attributes.
1535     * @param int          $post_ID Post ID.
1536     * @return string|false Inline player markup, or the untouched HTML.
1537     */
1538    public static function maybe_render_oembed_inline_player( $cache, $url, $attr, $post_ID = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1539        if ( ! is_string( $cache ) || false === strpos( $cache, '<iframe' ) ) {
1540            return $cache;
1541        }
1542
1543        $inline = self::render_inline_player_for_url( $url );
1544
1545        return null === $inline ? $cache : $inline;
1546    }
1547
1548    /**
1549     * Build inline player markup for a videopress.com URL, honoring the player parameters in its query string.
1550     *
1551     * @param string $url The URL being embedded.
1552     * @return string|null Markup, or null when the URL is not a VideoPress video or the inline player is off.
1553     */
1554    private static function render_inline_player_for_url( $url ) {
1555        if ( ! Inline_Player::is_enabled() ) {
1556            return null;
1557        }
1558
1559        $guid = Utils::extract_videopress_guid_from_url( $url );
1560        if ( null === $guid ) {
1561            return null;
1562        }
1563
1564        $attributes = Inline_Player::get_attributes_from_embed_url( $url );
1565
1566        return Inline_Player::render(
1567            $guid,
1568            Inline_Player::get_player_options( $attributes ),
1569            null,
1570            array( 'poster' => Inline_Player::get_poster_url( $guid, $attributes ) )
1571        );
1572    }
1573}