PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-videopress / src / class-initializer.php

class-initializer.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at jetpack_vendor/automattic/jetpack-videopress/src/class-initializer.php

1,186 lines 42.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The initializer class for the videopress package
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8 namespace Automattic\Jetpack\VideoPress;
9
10 /**
11 * Initialized the VideoPress package
12 */
13 class 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
23 const JETPACK_VIDEOPRESS_IFRAME_API_HANDLER = 'jetpack-videopress-iframe-api';
24
25 /**
26 * Initialization optinos
27 *
28 * @var array
29 */
30 protected static $init_options = array();
31
32 /**
33 * Initializes the VideoPress package
34 *
35 * This method is called by Config::ensure.
36 *
37 * @return void
38 */
39 public static function init() {
40 if ( ! did_action( 'videopress_init' ) ) {
41
42 self::unconditional_initialization();
43
44 if ( Status::is_active() ) {
45 self::active_initialization();
46 } elseif ( self::should_initialize_admin_ui() ) {
47 // Keep "Jetpack > VideoPress" in the menu when the module is not
48 // active, linking to the My Jetpack interstitial to activate it.
49 Admin_UI::init_inactive_menu();
50 }
51 }
52
53 /**
54 * Fires after the VideoPress package is initialized
55 *
56 * @since 0.1.1
57 */
58 do_action( 'videopress_init' );
59 }
60
61 /**
62 * Update the initialization options
63 *
64 * This method is called by the Config class
65 *
66 * @param array $options The initialization options.
67 * @return void
68 */
69 public static function update_init_options( array $options ) {
70 if ( empty( $options['admin_ui'] ) || self::should_initialize_admin_ui() ) { // do not overwrite if already set to true.
71 return;
72 }
73
74 self::$init_options['admin_ui'] = $options['admin_ui'];
75 }
76
77 /**
78 * Checks the initialization options and returns whether the admin_ui should be initialized or not
79 *
80 * @return boolean
81 */
82 public static function should_initialize_admin_ui() {
83 return isset( self::$init_options['admin_ui'] ) && true === self::$init_options['admin_ui'];
84 }
85
86 /**
87 * Initialize VideoPress features that should be initialized whenever VideoPress is present, even if the module is not active
88 *
89 * @return void
90 */
91 private static function unconditional_initialization() {
92 if ( self::should_include_utilities() ) {
93 require_once __DIR__ . '/utility-functions.php';
94 }
95
96 // Set up package version hook.
97 add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
98
99 /*
100 * Keep the videopress_guid attachment meta out of reach of the
101 * user-facing meta write APIs (Custom Fields, XML-RPC set_custom_fields,
102 * the WordPress.com JSON API metadata op, REST). The post <-> guid
103 * mapping is what the VideoPress meta and poster endpoints authorize
104 * against, so a writable guid would let a caller point an object they
105 * can edit at somebody else's video and still pass the edit_post check.
106 *
107 * These auth_* filters are consulted by map_meta_cap() for the
108 * add/edit/delete_post_meta capabilities only, so unlike marking the key
109 * protected they leave is_protected_meta() false and meta_key queries
110 * against the WordPress.com JSON API keep working. VideoPress writes the
111 * mapping itself with update_post_meta(), which does not consult
112 * capabilities, so uploads and transcoding are unaffected.
113 *
114 * The subtype-specific filter covers attachments; the generic one covers
115 * calls made before a subtype can be resolved.
116 */
117 add_filter( 'auth_post_meta_videopress_guid_for_attachment', '__return_false' );
118 add_filter( 'auth_post_meta_videopress_guid', '__return_false' );
119
120 Module_Control::init();
121
122 /*
123 * The WPCOM REST API v2 endpoints only register routes/fields on REST
124 * init, so defer constructing them (and autoloading their classes) until
125 * a REST request is actually served. Registered on both REST init hooks
126 * so the routes remain available in every context they were before, and
127 * guarded so the endpoints are instantiated only once per request.
128 */
129 $register_rest_api_v2_endpoints = static function () {
130 static $registered = false;
131 if ( $registered ) {
132 return;
133 }
134 $registered = true;
135 new WPCOM_REST_API_V2_Endpoint_VideoPress();
136 new WPCOM_REST_API_V2_Endpoint_VideoPress_Caption_Tracks();
137 new WPCOM_REST_API_V2_Attachment_VideoPress_Field();
138 new WPCOM_REST_API_V2_Attachment_VideoPress_Data();
139 new WPCOM_REST_API_V2_Endpoint_VideoPress_Edits();
140 };
141 add_action( 'rest_api_init', $register_rest_api_v2_endpoints, 0 );
142 add_action( 'restapi_theme_init', $register_rest_api_v2_endpoints, 0 );
143
144 if ( is_admin() ) {
145 AJAX::init();
146 } else {
147 require_once __DIR__ . '/class-block-replacement.php';
148 Block_Replacement::init();
149 }
150 }
151
152 /**
153 * This avoids conflicts when running VideoPress plugin with older versions of the Jetpack plugin
154 *
155 * On version 11.3-a.7 utility functions include were removed from the plugin and it is safe to include it from the package
156 *
157 * @return boolean
158 */
159 private static function should_include_utilities() {
160 if ( ! class_exists( 'Jetpack' ) || ! defined( 'JETPACK__VERSION' ) ) {
161 return true;
162 }
163
164 return version_compare( JETPACK__VERSION, '11.3-a.7', '>=' );
165 }
166
167 /**
168 * Prepare a poster URL for a quoted CSS url() inside an HTML attribute.
169 *
170 * @param mixed $poster Poster URL.
171 * @return string Sanitized and HTML-encoded poster URL, or an empty string.
172 */
173 private static function prepare_poster_url_for_inline_style( $poster ) {
174 if ( ! is_string( $poster ) || '' === $poster ) {
175 return '';
176 }
177
178 /*
179 * Decode one layer so ordinarily encoded URLs retain their semantics. Any
180 * remaining entities are encoded again below and stay inert after the HTML
181 * parser performs its single decoding pass.
182 */
183 $poster_url = esc_url_raw( html_entity_decode( $poster, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
184 if ( '' === $poster_url ) {
185 return '';
186 }
187
188 /*
189 * Force existing character references to be encoded. esc_attr() preserves
190 * them, but this value crosses from an HTML attribute into a CSS string.
191 */
192 return htmlspecialchars( $poster_url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true );
193 }
194
195 /**
196 * Initialize VideoPress features that should be initialized only when the module is active
197 *
198 * @return void
199 */
200 private static function active_initialization() {
201 Attachment_Handler::init();
202 Jwt_Token_Bridge::init();
203 Caption_Tracks::init();
204 Initial_State::init();
205 XMLRPC::init();
206 Block_Editor_Content::init();
207 Playlist_Index::init();
208
209 /*
210 * These endpoints only add their routes on REST init, so defer calling
211 * init() (and autoloading the endpoint classes) until a REST request is
212 * served. Priority 0 ensures the routes still register before the
213 * default-priority rest_api_init handlers run. Class-name strings are
214 * used so the classes are not autoloaded on non-REST requests.
215 */
216 foreach (
217 array(
218 Uploader_Rest_Endpoints::class,
219 Rest_Controller::class,
220 VideoPress_Rest_Api_V1_Stats::class,
221 VideoPress_Rest_Api_V1_Site::class,
222 VideoPress_Rest_Api_V1_Settings::class,
223 VideoPress_Rest_Api_V1_Features::class,
224 ) as $rest_endpoint
225 ) {
226 add_action( 'rest_api_init', array( $rest_endpoint, 'init' ), 0 );
227 }
228 self::register_oembed_providers();
229
230 // In inline mode a VideoPress URL never needs the oEmbed round trip: skip
231 // the fetch, and swap iframes already cached in post meta for a placeholder.
232 add_filter( 'pre_oembed_result', array( __CLASS__, 'maybe_pre_oembed_inline_player' ), 10, 2 );
233 add_filter( 'embed_oembed_html', array( __CLASS__, 'maybe_render_oembed_inline_player' ), 5, 4 );
234
235 // Enqueuethe VideoPress Iframe API script in the front-end.
236 add_filter( 'embed_oembed_html', array( __CLASS__, 'enqueue_videopress_iframe_api_script' ), 10, 4 );
237
238 if ( self::should_initialize_admin_ui() ) {
239 Admin_UI::init();
240 }
241
242 Divi::init();
243 }
244
245 /**
246 * Explicitly register VideoPress oembed provider for patterns not supported by core
247 *
248 * @return void
249 */
250 public static function register_oembed_providers() {
251 $host = rawurlencode( home_url() );
252 // videopress.com/v is already registered in core.
253 // By explicitly declaring the provider here, we can speed things up by not relying on oEmbed discovery.
254 wp_oembed_add_provider( '#^https?://video.wordpress.com/v/.*#', 'https://public-api.wordpress.com/oembed/?for=' . $host, true );
255 // This is needed as it's not supported in oEmbed discovery.
256 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
257
258 add_filter( 'embed_oembed_html', array( __CLASS__, 'video_enqueue_bridge_when_oembed_present' ), 10, 4 );
259 }
260
261 /**
262 * Enqueues VideoPress token bridge when a VideoPress oembed is present on the current page.
263 *
264 * @param string|false $cache The cached HTML result, stored in post meta.
265 * @param string $url The attempted embed URL.
266 * @param array $attr An array of shortcode attributes.
267 * @param int $post_ID Post ID.
268 *
269 * @return string|false
270 */
271 public static function video_enqueue_bridge_when_oembed_present( $cache, $url, $attr, $post_ID = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
272 if ( Utils::is_videopress_url( $url ) ) {
273 Jwt_Token_Bridge::enqueue_jwt_token_bridge();
274 }
275
276 return $cache;
277 }
278
279 /**
280 * Register all VideoPress blocks
281 *
282 * @return void
283 */
284 public static function register_videopress_blocks() {
285 // Register VideoPress Video block.
286 self::register_videopress_video_block();
287
288 // Register Video Playlist block.
289 self::register_videopress_playlist_block();
290
291 // Register Latest Videos Playlist block.
292 self::register_videopress_latest_videos_playlist_block();
293
294 // Register All Playlists block.
295 self::register_videopress_all_playlists_block();
296 }
297
298 /**
299 * Register the All Playlists block, which lists the site's Video Playlist
300 * blocks from the playlist index.
301 *
302 * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
303 * package build output; tests can point it at a fixture.
304 *
305 * @return void
306 */
307 public static function register_videopress_all_playlists_block( $metadata_file = null ) {
308 All_Playlists_Block::register( $metadata_file );
309 }
310
311 /**
312 * VideoPress video block render method
313 *
314 * @global \WP_Embed $wp_embed WordPress embed handler.
315 *
316 * @param array $block_attributes Block attributes.
317 * @param string $content Current block markup.
318 * @param \WP_Block $block Current block.
319 *
320 * @return string Block markup.
321 */
322 public static function render_videopress_video_block( $block_attributes, $content, $block ) {
323 global $wp_embed;
324
325 // Pre-build and cache the GUID list for this post to optimize authorization checks,
326 // and record the GUID actually being rendered: by render time WordPress has expanded
327 // synced patterns, templates, and template parts, so this covers embedding contexts
328 // the static content scan cannot see.
329 $post_id = $block->context['postId'] ?? get_the_ID();
330 if ( ! empty( $post_id ) && isset( $block_attributes['guid'] ) && is_string( $block_attributes['guid'] ) ) {
331 Access_Control::ensure_post_guids_cached( absint( $post_id ), $block_attributes['guid'] );
332 } elseif ( ! empty( $post_id ) ) {
333 Access_Control::build_and_cache_post_guids( absint( $post_id ) );
334 }
335
336 // CSS classes.
337 $align = $block_attributes['align'] ?? null;
338 $align_class = $align ? ' align' . $align : '';
339 $custom_class = isset( $block_attributes['className'] ) ? ' ' . $block_attributes['className'] : '';
340 $classes = 'wp-block-jetpack-videopress jetpack-videopress-player' . $custom_class . $align_class;
341
342 // Inline style.
343 $style = '';
344 $max_width = isset( $block_attributes['maxWidth'] ) && is_string( $block_attributes['maxWidth'] )
345 ? trim( $block_attributes['maxWidth'] )
346 : '';
347
348 // maxWidth is rendered into an inline style. Accept only a plain CSS length
349 // or percentage so the value stays a single, well-formed declaration;
350 // anything else is dropped and the block renders at full width (as with the
351 // "100%" default).
352 if ( '' !== $max_width && '100%' !== $max_width
353 && preg_match( '/^\d+(\.\d+)?(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/i', $max_width )
354 ) {
355 $style = sprintf( 'max-width: %s;', $max_width );
356 $classes .= ' wp-block-jetpack-videopress--has-max-width';
357 }
358
359 /*
360 * <figcaption /> element
361 * Caption is stored into the block attributes,
362 * but also it was stored into the <figcaption /> element,
363 * meaning that it could be stored in two different places.
364 */
365 $figcaption = '';
366
367 // Caption from block attributes.
368 $caption = $block_attributes['caption'] ?? null;
369
370 /*
371 * If the caption is not stored into the block attributes,
372 * try to get it from the <figcaption /> element.
373 */
374 if ( null === $caption ) {
375 preg_match( '/<figcaption>(.*?)<\/figcaption>/', $content, $matches );
376 $caption = $matches[1] ?? null;
377 }
378
379 // If we have a caption, create the <figcaption /> element.
380 if ( null !== $caption ) {
381 $figcaption = sprintf( '<figcaption>%s</figcaption>', wp_kses_post( $caption ) );
382 }
383
384 // Custom anchor from block content.
385 $id_attribute = '';
386
387 // Try to get the custom anchor from the block attributes.
388 if ( isset( $block_attributes['anchor'] ) && $block_attributes['anchor'] ) {
389 $id_attribute = sprintf( 'id="%s"', esc_attr( $block_attributes['anchor'] ) );
390 } elseif ( preg_match( '/<figure[^>]*id="([^"]+)"/', $content, $matches ) ) {
391 // Otherwise, try to get the custom anchor from the <figure /> element.
392 $id_attribute = sprintf( 'id="%s"', esc_attr( $matches[1] ) );
393 }
394
395 // Preview On Hover data.
396 $is_poh_enabled =
397 isset( $block_attributes['posterData']['previewOnHover'] ) &&
398 $block_attributes['posterData']['previewOnHover'];
399
400 $autoplay = $block_attributes['autoplay'] ?? false;
401 $controls = $block_attributes['controls'] ?? false;
402 $poster = $block_attributes['posterData']['url'] ?? null;
403
404 $preview_on_hover = '';
405
406 if ( $is_poh_enabled ) {
407 $preview_on_hover = array(
408 'previewAtTime' => $block_attributes['posterData']['previewAtTime'],
409 'previewLoopDuration' => $block_attributes['posterData']['previewLoopDuration'],
410 'autoplay' => $autoplay,
411 'showControls' => $controls,
412 );
413
414 // Create inline style in case video has a custom poster.
415 $inline_style = '';
416 $poster_url = self::prepare_poster_url_for_inline_style( $poster );
417 if ( $poster_url ) {
418 // Emit the poster URL as a double-quoted CSS string so it stays
419 // contained within url() and cannot affect the surrounding style.
420 $inline_style = sprintf(
421 'style="background-image: url(&quot;%s&quot;); background-size: cover; background-position: center center;"',
422 $poster_url
423 );
424 }
425
426 // Expose the preview on hover data to the client.
427 $preview_on_hover = sprintf(
428 '<div class="jetpack-videopress-player__overlay" %s></div><script type="application/json">%s</script>',
429 $inline_style,
430 wp_json_encode( $preview_on_hover, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
431 );
432
433 // Set `autoplay` and `muted` attributes to the video element.
434 $block_attributes['autoplay'] = true;
435 $block_attributes['muted'] = true;
436 }
437
438 $figure_template = '
439 <figure class="%1$s" style="%2$s" %3$s>
440 %4$s
441 %5$s
442 %6$s
443 </figure>
444 ';
445
446 // VideoPress URL.
447 $guid = $block_attributes['guid'] ?? null;
448 $videopress_url = Utils::get_video_press_url( $guid, $block_attributes );
449
450 $video_wrapper = '';
451 $video_wrapper_classes = 'jetpack-videopress-player__wrapper';
452
453 // Preview on hover drives the player through the iframe API, so it keeps the iframe.
454 if ( $guid && ! $is_poh_enabled && Inline_Player::is_enabled() ) {
455 $video_wrapper = sprintf(
456 '<div class="%s">%s</div>',
457 $video_wrapper_classes,
458 Inline_Player::render(
459 $guid,
460 Inline_Player::get_player_options( $block_attributes ),
461 $block_attributes['videoRatio'] ?? null,
462 array(
463 'poster' => Inline_Player::get_poster_url( $guid, $block_attributes ),
464 'title' => $block_attributes['title'] ?? '',
465 )
466 )
467 );
468 } elseif ( $videopress_url ) {
469 $videopress_url = wp_kses_post( $videopress_url );
470
471 /*
472 * Provide a fallback iframe for when the oEmbed endpoint fails, e.g.
473 * when the VideoPress backend isn't ready for a freshly uploaded video.
474 * This prevents the published page from showing a bare link.
475 */
476 $fallback = function ( $output, $url ) use ( $videopress_url ) {
477 $decoded_url = html_entity_decode( $videopress_url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
478 if ( $decoded_url !== $url ) {
479 return $output;
480 }
481
482 return sprintf(
483 '<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>',
484 esc_attr__( 'VideoPress Video Player', 'jetpack-videopress-pkg' ),
485 esc_url( preg_replace( '#/v/#', '/embed/', $url, 1 ) )
486 );
487 };
488
489 add_filter( 'embed_maybe_make_link', $fallback, 10, 2 );
490 $oembed_html = apply_filters( 'video_embed_html', $wp_embed->shortcode( array(), $videopress_url ) );
491 remove_filter( 'embed_maybe_make_link', $fallback );
492
493 $video_wrapper = sprintf(
494 '<div class="%s">%s %s</div>',
495 $video_wrapper_classes,
496 $preview_on_hover,
497 $oembed_html
498 );
499
500 /*
501 * Self-heal failed oEmbed cache for VideoPress URLs.
502 *
503 * When the VideoPress backend isn't ready for a freshly uploaded video,
504 * WordPress caches '{{unknown}}' in post meta with a TTL that is too long
505 * for this use case. Clear recent failures so the next page render retries
506 * oEmbed discovery, keeping the fallback iframe above temporary.
507 */
508 $post_id = $block->context['postId'] ?? get_the_ID();
509
510 if ( $post_id ) {
511 $key_suffix = md5( $videopress_url . serialize( wp_embed_defaults( $videopress_url ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matching WP_Embed cache key format.
512 $oembed_value = get_post_meta( $post_id, '_oembed_' . $key_suffix, true );
513 $oembed_time = (int) get_post_meta( $post_id, '_oembed_time_' . $key_suffix, true );
514
515 /*
516 * Only clear the '{{unknown}}' cache entry when it is recent, to avoid
517 * disabling WordPress's oEmbed backoff for persistent provider failures.
518 */
519 if (
520 '{{unknown}}' === $oembed_value
521 && ( ! $oembed_time || ( time() - $oembed_time ) < MINUTE_IN_SECONDS )
522 ) {
523 delete_post_meta( $post_id, '_oembed_' . $key_suffix );
524 delete_post_meta( $post_id, '_oembed_time_' . $key_suffix );
525 }
526 }
527 }
528
529 // Get premium content from block context.
530 $premium_block_plan_id = isset( $block->context['premium-content/planId'] ) ? intval( $block->context['premium-content/planId'] ) : 0;
531 $is_premium_content_child = isset( $block->context['isPremiumContentChild'] ) ? (bool) $block->context['isPremiumContentChild'] : false;
532 $maybe_premium_script = '';
533 if ( $is_premium_content_child && is_string( $guid ) ) {
534 Access_Control::instance()->set_guid_subscription( $guid, $premium_block_plan_id );
535 $escaped_guid = wp_json_encode( $guid, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
536 $script_content = "if ( ! window.__guidsToPlanIds ) { window.__guidsToPlanIds = {}; }; window.__guidsToPlanIds[$escaped_guid] = $premium_block_plan_id;";
537 $maybe_premium_script = '<script>' . $script_content . '</script>';
538 }
539
540 // $id_attribute, $video_wrapper, $figcaption properly escaped earlier in the code.
541 return sprintf(
542 $figure_template,
543 esc_attr( $classes ),
544 esc_attr( $style ),
545 $id_attribute,
546 $video_wrapper,
547 $figcaption,
548 $maybe_premium_script
549 );
550 }
551
552 /**
553 * Register the VideoPress block editor block,
554 * AKA "VideoPress Block v6".
555 *
556 * @return void
557 */
558 public static function register_videopress_video_block() {
559 /*
560 * If only Jetpack is active, and if the VideoPress module is not active,
561 * we can register the block just to display a placeholder to turn on the module.
562 * That invitation is only useful for admins though.
563 */
564 if (
565 Status::is_jetpack_plugin_without_videopress_module_active()
566 && ! Status::is_standalone_plugin_active()
567 && ! current_user_can( 'jetpack_activate_modules' )
568 ) {
569 return;
570 }
571
572 $videopress_video_metadata_file = __DIR__ . '/../build/block-editor/blocks/video/block.json';
573 $videopress_video_metadata_file_exists = file_exists( $videopress_video_metadata_file );
574 if ( ! $videopress_video_metadata_file_exists ) {
575 return;
576 }
577
578 $videopress_video_metadata = json_decode(
579 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
580 file_get_contents( $videopress_video_metadata_file )
581 );
582
583 // Pick the block name straight from the block metadata .json file.
584 $videopress_video_block_name = $videopress_video_metadata->name;
585
586 // Is the block already registered?
587 $is_block_registered = \WP_Block_Type_Registry::get_instance()->is_registered( $videopress_video_block_name );
588
589 // Do not register if the block is already registered.
590 if ( $is_block_registered ) {
591 return;
592 }
593
594 $registration = register_block_type(
595 $videopress_video_metadata_file,
596 array(
597 'render_callback' => array( __CLASS__, 'render_videopress_video_block' ),
598 'render_email_callback' => array( Video_Block_Email_Renderer::class, 'render' ),
599 'uses_context' => array( 'premium-content/planId', 'isPremiumContentChild', 'selectedPlanId' ),
600 )
601 );
602
603 // Do not enqueue scripts if the block could not be registered.
604 if ( empty( $registration ) || empty( $registration->editor_script_handles ) ) {
605 return;
606 }
607
608 // Extensions use Connection_Initial_State::render_script with script handle as parameter.
609 if ( is_array( $registration->editor_script_handles ) ) {
610 $script_handle = $registration->editor_script_handles[0];
611 } else {
612 $script_handle = $registration->editor_script_handles;
613 }
614
615 // Register and enqueue scripts used by the VideoPress video block.
616 Block_Editor_Extensions::init( $script_handle );
617 }
618
619 /**
620 * Register the Video Playlist block.
621 *
622 * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
623 * package build output; tests can point it at a fixture.
624 *
625 * @return void
626 */
627 public static function register_videopress_playlist_block( $metadata_file = null ) {
628 /*
629 * Unlike the video block, the playlist block has no "activate the module"
630 * placeholder, so it is only registered where VideoPress can play videos.
631 */
632 if (
633 Status::is_jetpack_plugin_without_videopress_module_active()
634 && ! Status::is_standalone_plugin_active()
635 ) {
636 return;
637 }
638
639 if ( null === $metadata_file ) {
640 $metadata_file = __DIR__ . '/../build/block-editor/blocks/playlist/block.json';
641 }
642
643 if ( ! file_exists( $metadata_file ) ) {
644 return;
645 }
646
647 $metadata = json_decode(
648 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
649 file_get_contents( $metadata_file )
650 );
651
652 if ( empty( $metadata->name )
653 || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
654 ) {
655 return;
656 }
657
658 register_block_type(
659 $metadata_file,
660 array(
661 'render_callback' => array( __CLASS__, 'render_videopress_playlist_block' ),
662 )
663 );
664 }
665
666 /**
667 * Register the Latest Videos Playlist block.
668 *
669 * It reuses the Video Playlist block's registered view script and styles, so
670 * it is only registered once that block is. Its inner Video Playlist block is
671 * the editor canvas only: the front end renders the newest videos fresh.
672 *
673 * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
674 * package build output; tests can point it at a fixture.
675 *
676 * @return void
677 */
678 public static function register_videopress_latest_videos_playlist_block( $metadata_file = null ) {
679 if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) {
680 return;
681 }
682
683 if ( null === $metadata_file ) {
684 $metadata_file = __DIR__ . '/../build/block-editor/blocks/latest-videos-playlist/block.json';
685 }
686
687 if ( ! file_exists( $metadata_file ) ) {
688 return;
689 }
690
691 $metadata = json_decode(
692 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
693 file_get_contents( $metadata_file )
694 );
695
696 if ( empty( $metadata->name )
697 || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
698 ) {
699 return;
700 }
701
702 register_block_type(
703 $metadata_file,
704 array(
705 'render_callback' => array( __CLASS__, 'render_videopress_latest_videos_playlist_block' ),
706 'skip_inner_blocks' => true,
707 )
708 );
709 }
710
711 /**
712 * Latest Videos Playlist block render callback: the newest VideoPress videos
713 * on the site, rendered by the Video Playlist block's callback.
714 *
715 * @param array $block_attributes Block attributes.
716 * @param string $content Current block markup, unused: the inner block is never rendered.
717 * @param \WP_Block|null $block Current block.
718 *
719 * @return string Block markup, or an empty string when the site has no VideoPress videos.
720 */
721 public static function render_videopress_latest_videos_playlist_block( $block_attributes, $content = '', $block = null ) {
722 $count = isset( $block_attributes['count'] ) && is_numeric( $block_attributes['count'] )
723 ? (int) $block_attributes['count']
724 : self::LATEST_VIDEOS_PLAYLIST_DEFAULT_COUNT;
725 $count = max( self::LATEST_VIDEOS_PLAYLIST_MIN_COUNT, min( self::LATEST_VIDEOS_PLAYLIST_MAX_COUNT, $count ) );
726
727 $block_attributes['videos'] = Data::get_latest_videopress_playlist_entries( $count );
728
729 return self::render_videopress_playlist_block( $block_attributes, $content, $block );
730 }
731
732 /**
733 * Sanitize the playlist block's videos attribute into rendering-ready entries.
734 *
735 * @param mixed $videos Raw attribute value.
736 *
737 * @return array Entries with guid, title, durationMs, height and poster keys.
738 */
739 public static function sanitize_playlist_entries( $videos ) {
740 if ( ! is_array( $videos ) ) {
741 return array();
742 }
743
744 $entries = array();
745 foreach ( $videos as $video ) {
746 if ( ! is_array( $video ) || empty( $video['guid'] ) || ! is_string( $video['guid'] ) ) {
747 continue;
748 }
749
750 // VideoPress GUIDs are 8 alphanumeric characters; drop anything else.
751 if ( ! preg_match( '/^[a-zA-Z0-9]{8}$/', $video['guid'] ) ) {
752 continue;
753 }
754
755 /*
756 * Only the video reference and numeric metadata are stored. Display
757 * metadata (title, poster) is always live: the view script reads it
758 * from the video data after the page loads.
759 */
760 $entries[] = array(
761 'guid' => $video['guid'],
762 'durationMs' => isset( $video['durationMs'] ) && is_numeric( $video['durationMs'] ) ? max( 0, (int) $video['durationMs'] ) : 0,
763 'height' => isset( $video['height'] ) && is_numeric( $video['height'] ) ? max( 0, (int) $video['height'] ) : 0,
764 );
765 }
766
767 return $entries;
768 }
769
770 /**
771 * Build the VideoPress embed URL for a playlist entry.
772 *
773 * @param string $guid Video GUID.
774 * @param bool $autoplay Whether the video should start playing once loaded.
775 * @param bool $muted Whether playback should start muted.
776 *
777 * @return string Embed URL.
778 */
779 private static function playlist_embed_url( $guid, $autoplay, $muted = false ) {
780 $args = array(
781 'cover' => 1,
782 'preloadContent' => Data::get_videopress_player_preload_disabled() ? 'none' : 'metadata',
783 'autoPlay' => $autoplay ? 1 : 0,
784 );
785 if ( $muted ) {
786 $args['muted'] = 1;
787 }
788
789 return add_query_arg(
790 $args,
791 'https://videopress.com/embed/' . rawurlencode( $guid )
792 );
793 }
794
795 /**
796 * Format a duration in milliseconds as a timecode, m:ss or h:mm:ss.
797 *
798 * @param int $duration_ms Duration in milliseconds.
799 *
800 * @return string Timecode, or an empty string when the duration is unknown.
801 */
802 private static function playlist_timecode( $duration_ms ) {
803 if ( $duration_ms <= 0 ) {
804 return '';
805 }
806
807 $total_seconds = (int) round( $duration_ms / 1000 );
808 $hours = intdiv( $total_seconds, 3600 );
809 $minutes = intdiv( $total_seconds % 3600, 60 );
810 $seconds = $total_seconds % 60;
811
812 if ( $hours > 0 ) {
813 return sprintf( '%d:%02d:%02d', $hours, $minutes, $seconds );
814 }
815
816 return sprintf( '%d:%02d', $minutes, $seconds );
817 }
818
819 /**
820 * Format a duration in milliseconds as a long runtime, e.g. "1 hr 13 min".
821 *
822 * @param int $duration_ms Duration in milliseconds.
823 *
824 * @return string Runtime label, or an empty string when the duration is unknown.
825 */
826 private static function playlist_runtime_label( $duration_ms ) {
827 if ( $duration_ms <= 0 ) {
828 return '';
829 }
830
831 $total_minutes = max( 1, (int) round( $duration_ms / 60000 ) );
832 $hours = intdiv( $total_minutes, 60 );
833 $minutes = $total_minutes % 60;
834
835 if ( $hours > 0 && $minutes > 0 ) {
836 /* translators: 1: number of hours. 2: number of minutes. */
837 return sprintf( __( '%1$d hr %2$d min', 'jetpack-videopress-pkg' ), $hours, $minutes );
838 }
839
840 if ( $hours > 0 ) {
841 /* translators: %d: number of hours. */
842 return sprintf( __( '%d hr', 'jetpack-videopress-pkg' ), $hours );
843 }
844
845 /* translators: %d: number of minutes. */
846 return sprintf( __( '%d min', 'jetpack-videopress-pkg' ), $minutes );
847 }
848
849 /**
850 * Map a video's pixel height to a resolution label, e.g. "1080p" or "4K".
851 *
852 * @param int $height Video height in pixels.
853 *
854 * @return string Resolution label, or an empty string when the height is unknown.
855 */
856 private static function playlist_resolution_label( $height ) {
857 if ( $height <= 0 ) {
858 return '';
859 }
860
861 return $height >= 2160 ? '4K' : $height . 'p';
862 }
863
864 /**
865 * Video Playlist block render callback.
866 *
867 * @param array $block_attributes Block attributes.
868 * @param string $content Current block markup.
869 * @param \WP_Block|null $block Current block.
870 *
871 * @return string Block markup, or an empty string when the playlist has no playable entries.
872 */
873 public static function render_videopress_playlist_block( $block_attributes, $content = '', $block = null ) {
874 $entries = self::sanitize_playlist_entries( $block_attributes['videos'] ?? null );
875
876 if ( ! $entries ) {
877 return '';
878 }
879
880 // Record the rendered GUIDs in the post's cached GUID list so private playlist
881 // entries pass the playback authorization check, including when the playlist
882 // sits inside a synced pattern, template, or template part.
883 $post_id = $block->context['postId'] ?? get_the_ID();
884 if ( ! empty( $post_id ) ) {
885 Access_Control::ensure_post_guids_cached( absint( $post_id ), array_column( $entries, 'guid' ) );
886 }
887
888 $enabled = function ( $key, $default_value = true ) use ( $block_attributes ) {
889 return isset( $block_attributes[ $key ] ) ? (bool) $block_attributes[ $key ] : $default_value;
890 };
891
892 $layout = isset( $block_attributes['layout'] ) && in_array( $block_attributes['layout'], array( 'side-rail', 'grid', 'strip' ), true )
893 ? $block_attributes['layout']
894 : 'side-rail';
895
896 $show_thumbnail = $enabled( 'showThumbnail' );
897 $show_title = $enabled( 'showTitle' );
898 $show_res = $enabled( 'showResolution' );
899 $show_duration = $enabled( 'showDuration' );
900 $show_number = $enabled( 'showPositionNumber', false );
901 $show_runtime = $enabled( 'showTotalRuntime' );
902 $muted = $enabled( 'muteByDefault', false );
903
904 $classes = array( 'videopress-playlist', 'is-layout-' . $layout );
905 if ( $enabled( 'darkPlayer', false ) ) {
906 $classes[] = 'is-dark';
907 }
908 if ( ! $show_thumbnail ) {
909 $classes[] = 'hide-thumbnails';
910 }
911 if ( ! $show_title ) {
912 $classes[] = 'hide-titles';
913 }
914 if ( ! $show_res ) {
915 $classes[] = 'hide-resolutions';
916 }
917 if ( ! $show_duration ) {
918 $classes[] = 'hide-durations';
919 }
920 if ( ! $show_runtime ) {
921 $classes[] = 'hide-runtime';
922 }
923
924 // Lets the embed play private videos for authorized viewers.
925 Jwt_Token_Bridge::enqueue_jwt_token_bridge();
926
927 $count = count( $entries );
928 $total_ms = 0;
929 foreach ( $entries as $entry ) {
930 $total_ms += $entry['durationMs'];
931 }
932
933 $total_timecode = self::playlist_timecode( $total_ms );
934 /* translators: %d: number of videos in the playlist. */
935 $count_label = sprintf( _n( '%d video', '%d videos', $count, 'jetpack-videopress-pkg' ), $count );
936
937 /*
938 * Hidden placeholder shown (via the button's is-locked class) when the view
939 * script cannot authorize a private video's thumbnail for the viewer.
940 */
941 $lock_markup = '<span class="videopress-playlist__entry-lock">'
942 . '<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>'
943 . '<span class="videopress-playlist__entry-lock-label">' . esc_html__( 'Private video', 'jetpack-videopress-pkg' ) . '</span>'
944 . '</span>';
945
946 $items = '';
947 foreach ( $entries as $index => $entry ) {
948 /*
949 * Positional fallback only: the view script replaces titles (and
950 * adds posters) with live video data once the page loads.
951 */
952 /* translators: %d: position of the video in the playlist. */
953 $title = sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 );
954
955 $timecode = self::playlist_timecode( $entry['durationMs'] );
956 $resolution = self::playlist_resolution_label( $entry['height'] );
957 /* translators: 1: position of the video in the playlist. 2: number of videos in the playlist. */
958 $position = sprintf( __( '%1$d of %2$d', 'jetpack-videopress-pkg' ), $index + 1, $count );
959 $details = implode( ' · ', array_filter( array( $resolution, $timecode ) ) );
960 $progress = '' !== $total_timecode
961 /* translators: 1: position of the video in the playlist. 2: number of videos. 3: total playlist timecode. */
962 ? sprintf( __( '%1$d / %2$d · %3$s total', 'jetpack-videopress-pkg' ), $index + 1, $count, $total_timecode )
963 /* translators: 1: position of the video in the playlist. 2: number of videos. */
964 : sprintf( __( '%1$d / %2$d', 'jetpack-videopress-pkg' ), $index + 1, $count );
965
966 $number_markup = $show_number
967 ? sprintf(
968 '<span class="videopress-playlist__entry-number">%s</span>',
969 esc_html( str_pad( (string) ( $index + 1 ), 2, '0', STR_PAD_LEFT ) )
970 )
971 : '';
972
973 $time_markup = '' !== $timecode
974 ? sprintf( '<span class="videopress-playlist__entry-time">%s</span>', esc_html( $timecode ) )
975 : '';
976
977 $resolution_markup = '' !== $resolution
978 ? sprintf( '<span class="videopress-playlist__entry-resolution">%s</span>', esc_html( $resolution ) )
979 : '';
980
981 $duration_markup = '' !== $timecode
982 ? sprintf( '<span class="videopress-playlist__entry-duration">%s</span>', esc_html( $timecode ) )
983 : '';
984
985 $items .= sprintf(
986 '<li class="videopress-playlist__entry"><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">' .
987 '%9$s<span class="videopress-playlist__entry-thumb"><span class="videopress-playlist__entry-flag">%10$s</span>%15$s%11$s</span>' .
988 '<span class="videopress-playlist__entry-body"><span class="videopress-playlist__entry-title">%12$s</span><span class="videopress-playlist__entry-meta">%13$s%14$s</span></span>' .
989 '</button></li>',
990 0 === $index ? ' is-current' : '',
991 0 === $index ? ' aria-current="true"' : '',
992 esc_attr( $entry['guid'] ),
993 esc_url( self::playlist_embed_url( $entry['guid'], true, $muted ) ),
994 esc_attr( $title ),
995 esc_attr( $position ),
996 esc_attr( $details ),
997 esc_attr( $progress ),
998 $number_markup,
999 esc_html__( 'Playing', 'jetpack-videopress-pkg' ),
1000 $time_markup,
1001 esc_html( $title ),
1002 $resolution_markup,
1003 $duration_markup,
1004 $lock_markup
1005 );
1006 }
1007
1008 $first = $entries[0];
1009 $first_title = __( 'Video 1', 'jetpack-videopress-pkg' );
1010 $runtime_label = self::playlist_runtime_label( $total_ms );
1011 $runtime_markup = $show_runtime && '' !== $runtime_label
1012 ? sprintf( '<span class="videopress-playlist__runtime">%s</span>', esc_html( $runtime_label ) )
1013 : '';
1014
1015 // Count · runtime meta line, shown by the side-rail layout in the list header.
1016 $list_meta_markup = sprintf(
1017 '<span class="videopress-playlist__list-meta"><span class="videopress-playlist__count">%s</span>%s</span>',
1018 esc_html( $count_label ),
1019 $runtime_markup
1020 );
1021
1022 /*
1023 * The player renders its own title overlay, so the stage carries no
1024 * duplicate now-playing text — only the grid layout's runtime line.
1025 */
1026 $now_markup = $show_runtime && '' !== $runtime_label
1027 ? sprintf(
1028 '<div class="videopress-playlist__now"><span class="videopress-playlist__now-runtime">%s</span></div>',
1029 esc_html( $count_label . ' · ' . $runtime_label )
1030 )
1031 : '';
1032
1033 $stage_markup = sprintf(
1034 '<div class="videopress-playlist__stage">' .
1035 '<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>',
1036 esc_attr( $first_title ),
1037 esc_url( self::playlist_embed_url( $first['guid'], false, $muted ) ),
1038 $now_markup
1039 );
1040
1041 $progress_markup = '' !== $total_timecode
1042 ? sprintf(
1043 '<span class="videopress-playlist__list-progress">%s</span>',
1044 /* translators: 1: position of the current video. 2: number of videos. 3: total playlist timecode. */
1045 esc_html( sprintf( __( '%1$d / %2$d · %3$s total', 'jetpack-videopress-pkg' ), 1, $count, $total_timecode ) )
1046 )
1047 : '';
1048
1049 $list_markup = sprintf(
1050 '<div class="videopress-playlist__list">' .
1051 '<div class="videopress-playlist__list-header">' .
1052 '<span class="videopress-playlist__list-label videopress-playlist__list-label--rail">%1$s</span>' .
1053 '<span class="videopress-playlist__list-label videopress-playlist__list-label--strip">%2$s</span>%3$s%4$s</div>' .
1054 '<ol class="videopress-playlist__entries">%5$s</ol></div>',
1055 esc_html__( 'Up next', 'jetpack-videopress-pkg' ),
1056 /* translators: %s: number of videos in the playlist, e.g. "5 videos". */
1057 esc_html( sprintf( __( 'Playlist — %s', 'jetpack-videopress-pkg' ), $count_label ) ),
1058 $list_meta_markup,
1059 $progress_markup,
1060 $items
1061 );
1062
1063 /*
1064 * User-selected theme font presets (theme.json slugs) for the
1065 * customizable titles, exposed as CSS custom properties the
1066 * stylesheet reads. Anything but a plain preset slug is dropped.
1067 */
1068 $font_style = '';
1069 foreach ( array(
1070 'entryTitleFontFamily' => '--vpp-entry-title-font',
1071 ) as $font_attribute => $css_variable ) {
1072 if ( ! empty( $block_attributes[ $font_attribute ] )
1073 && is_string( $block_attributes[ $font_attribute ] )
1074 && preg_match( '/^[a-zA-Z0-9-]+$/', $block_attributes[ $font_attribute ] )
1075 ) {
1076 $font_style .= $css_variable . ':var(--wp--preset--font-family--' . $block_attributes[ $font_attribute ] . ');';
1077 }
1078 }
1079
1080 // Looping implies auto-advancing, so it forces the autoplay-next flag on.
1081 $loop_playlist = $enabled( 'loopPlaylist', false );
1082
1083 $wrapper_extra_attributes = array(
1084 'class' => implode( ' ', $classes ),
1085 'data-autoplay-next' => $enabled( 'autoplayNext', false ) || $loop_playlist ? '1' : '0',
1086 'data-loop' => $loop_playlist ? '1' : '0',
1087 );
1088 if ( '' !== $font_style ) {
1089 $wrapper_extra_attributes['style'] = $font_style;
1090 }
1091
1092 $wrapper_attributes = get_block_wrapper_attributes( $wrapper_extra_attributes );
1093
1094 return sprintf(
1095 '<figure %1$s><div class="videopress-playlist__body">%2$s%3$s</div></figure>',
1096 $wrapper_attributes,
1097 $stage_markup,
1098 $list_markup
1099 );
1100 }
1101
1102 /**
1103 * Enqueue the VideoPress Iframe API script
1104 * when the URL of oEmbed HTML is a VideoPress URL.
1105 *
1106 * @param string|false $cache The cached HTML result, stored in post meta.
1107 * @param string $url The attempted embed URL.
1108 * @param array $attr An array of shortcode attributes.
1109 * @param int $post_ID Post ID.
1110 *
1111 * @return string|false
1112 */
1113 public static function enqueue_videopress_iframe_api_script( $cache, $url, $attr, $post_ID ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1114 if ( Utils::is_videopress_url( $url ) && ! Inline_Player::is_enabled() ) {
1115 // Enqueue the VideoPress IFrame API in the front-end.
1116 wp_enqueue_script(
1117 self::JETPACK_VIDEOPRESS_IFRAME_API_HANDLER,
1118 'https://s0.wp.com/wp-content/plugins/video/assets/js/videojs/videopress-iframe-api.js',
1119 array(),
1120 gmdate( 'YW' ),
1121 false
1122 );
1123 }
1124
1125 return $cache;
1126 }
1127
1128 /**
1129 * Short-circuit the oEmbed request for VideoPress URLs when the inline player is on.
1130 *
1131 * @param null|string $result The oEmbed result, null to let WordPress fetch it.
1132 * @param string $url The URL being embedded.
1133 * @return null|string Inline player markup, or the untouched result.
1134 */
1135 public static function maybe_pre_oembed_inline_player( $result, $url ) {
1136 $inline = self::render_inline_player_for_url( $url );
1137
1138 return null === $inline ? $result : $inline;
1139 }
1140
1141 /**
1142 * Replace a VideoPress oEmbed iframe (fresh or cached) with an inline player when the inline player is on.
1143 *
1144 * @param string|false $cache The oEmbed HTML.
1145 * @param string $url The URL being embedded.
1146 * @param array $attr Shortcode attributes.
1147 * @param int $post_ID Post ID.
1148 * @return string|false Inline player markup, or the untouched HTML.
1149 */
1150 public static function maybe_render_oembed_inline_player( $cache, $url, $attr, $post_ID = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1151 if ( ! is_string( $cache ) || false === strpos( $cache, '<iframe' ) ) {
1152 return $cache;
1153 }
1154
1155 $inline = self::render_inline_player_for_url( $url );
1156
1157 return null === $inline ? $cache : $inline;
1158 }
1159
1160 /**
1161 * Build inline player markup for a videopress.com URL, honoring the player parameters in its query string.
1162 *
1163 * @param string $url The URL being embedded.
1164 * @return string|null Markup, or null when the URL is not a VideoPress video or the inline player is off.
1165 */
1166 private static function render_inline_player_for_url( $url ) {
1167 if ( ! Inline_Player::is_enabled() ) {
1168 return null;
1169 }
1170
1171 $guid = Utils::extract_videopress_guid_from_url( $url );
1172 if ( null === $guid ) {
1173 return null;
1174 }
1175
1176 $attributes = Inline_Player::get_attributes_from_embed_url( $url );
1177
1178 return Inline_Player::render(
1179 $guid,
1180 Inline_Player::get_player_options( $attributes ),
1181 null,
1182 array( 'poster' => Inline_Player::get_poster_url( $guid, $attributes ) )
1183 );
1184 }
1185 }
1186