Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
77.25% |
360 / 466 |
|
20.00% |
2 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Podcast_Episode_Block | |
77.25% |
360 / 466 |
|
20.00% |
2 / 10 |
437.76 | |
0.00% |
0 / 1 |
| register_hooks | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| register_block | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
| flag_plan_gated | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
| enqueue_view_script | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| load_editor_scripts | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| filter_editor_script_src | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| format_seconds_label | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| resolve_cover_art_url | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
7.90 | |||
| render_block | |
85.47% |
247 / 289 |
|
0.00% |
0 / 1 |
147.14 | |||
| render_email | |
82.08% |
87 / 106 |
|
0.00% |
0 / 1 |
29.89 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Podcast Episode block. |
| 4 | * |
| 5 | * @package automattic/jetpack-podcast |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Podcast; |
| 9 | |
| 10 | use Automattic\Jetpack\Assets; |
| 11 | use Automattic\Jetpack\Blocks; |
| 12 | |
| 13 | /** |
| 14 | * Registers and renders the Podcast Episode block. |
| 15 | */ |
| 16 | class Podcast_Episode_Block { |
| 17 | |
| 18 | /** |
| 19 | * Editor script handle. |
| 20 | */ |
| 21 | const EDITOR_HANDLE = 'jetpack-podcast-episode-editor'; |
| 22 | |
| 23 | /** |
| 24 | * Front-end + editor shared style handle. Side-loaded by |
| 25 | * `Assets::register_script` from the sibling `style.css` bundle. |
| 26 | */ |
| 27 | const STYLE_HANDLE = 'jetpack-block-podcast-episode'; |
| 28 | |
| 29 | /** |
| 30 | * Front-end view script handle. Enqueued from the render callback because |
| 31 | * block.json `viewScript` can't resolve the package's dist dir. |
| 32 | */ |
| 33 | const VIEW_HANDLE = 'jetpack-podcast-episode-view'; |
| 34 | |
| 35 | /** |
| 36 | * Wire the block's actions. |
| 37 | */ |
| 38 | public static function register_hooks() { |
| 39 | add_action( 'init', array( __CLASS__, 'register_block' ), 9 ); |
| 40 | add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 ); |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Register the block and its front-end style bundle (auto-enqueued via the |
| 45 | * `style` arg whenever the block renders). |
| 46 | */ |
| 47 | public static function register_block() { |
| 48 | // Side-loads the sibling style.css and registers it under STYLE_HANDLE. |
| 49 | Assets::register_script( |
| 50 | self::STYLE_HANDLE, |
| 51 | '../../../dist/blocks/podcast-episode/style.js', |
| 52 | __FILE__, |
| 53 | array( |
| 54 | 'css_path' => '../../../dist/blocks/podcast-episode/style.css', |
| 55 | ) |
| 56 | ); |
| 57 | |
| 58 | // Register in every context so the RSS feed carries the player โ how the |
| 59 | // WPCOM Reader shows it on Atomic/Jetpack sites. |
| 60 | Blocks::jetpack_register_block( |
| 61 | __DIR__, |
| 62 | array( |
| 63 | 'render_callback' => array( __CLASS__, 'render_block' ), |
| 64 | 'style' => self::STYLE_HANDLE, |
| 65 | 'render_email_callback' => array( __CLASS__, 'render_email' ), |
| 66 | ) |
| 67 | ); |
| 68 | |
| 69 | // Flag as plan-gated for the editor upgrade prompt. Priority 11 runs after |
| 70 | // jetpack_register_block's own availability action (10), so this wins. |
| 71 | // Admin-only: only the editor consumes this availability entry (the |
| 72 | // front-end render isn't availability-gated), and the self-hosted access |
| 73 | // check can hit WPCOM โ it must never run during a front-end page render. |
| 74 | if ( is_admin() && class_exists( \Jetpack_Gutenberg::class ) ) { |
| 75 | add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'flag_plan_gated' ), 11 ); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Mark the block plan-gated so the editor shows the upgrade prompt (no-op if |
| 81 | * the site has access). Standard paid-block behavior: nudge on Atomic/Simple, |
| 82 | * hidden on self-hosted Jetpack where nudges are off โ upsell in the dashboard. |
| 83 | */ |
| 84 | public static function flag_plan_gated() { |
| 85 | if ( Podcast_Gate::has_product_access() ) { |
| 86 | return; |
| 87 | } |
| 88 | |
| 89 | \Jetpack_Gutenberg::set_extension_unavailable( |
| 90 | 'podcast-episode', |
| 91 | 'missing_plan', |
| 92 | array( |
| 93 | 'required_feature' => 'podcast-episode', |
| 94 | 'required_plan' => Podcast_Gate::get_required_plan_slug(), |
| 95 | ) |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Register + enqueue the front-end view script (wires soundbite buttons). |
| 101 | * Called from render so it only ships on pages with the block; dedups internally. |
| 102 | */ |
| 103 | private static function enqueue_view_script() { |
| 104 | Assets::register_script( |
| 105 | self::VIEW_HANDLE, |
| 106 | '../../../dist/blocks/podcast-episode/view.js', |
| 107 | __FILE__, |
| 108 | array( |
| 109 | 'in_footer' => true, |
| 110 | 'enqueue' => true, |
| 111 | ) |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Enqueue the bundled editor script + style from the package's dist/. |
| 117 | */ |
| 118 | public static function load_editor_scripts() { |
| 119 | Assets::register_script( |
| 120 | self::EDITOR_HANDLE, |
| 121 | '../../../dist/blocks/podcast-episode/editor.js', |
| 122 | __FILE__, |
| 123 | array( |
| 124 | 'in_footer' => true, |
| 125 | 'enqueue' => true, |
| 126 | 'textdomain' => 'jetpack-podcast', |
| 127 | ) |
| 128 | ); |
| 129 | |
| 130 | // Only hook the src rewrite while the editor script is loading. |
| 131 | add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 ); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Rewrite the editor script src to the admin scheme, avoiding a mixed-content |
| 136 | * block on WPCOM custom domains without SSL (where `plugins_url()` returns |
| 137 | * http but wp-admin is https). |
| 138 | * |
| 139 | * @param string $src Script source URL. |
| 140 | * @param string $handle Script handle. |
| 141 | * @return string |
| 142 | */ |
| 143 | public static function filter_editor_script_src( $src, $handle ) { |
| 144 | if ( self::EDITOR_HANDLE !== $handle ) { |
| 145 | return $src; |
| 146 | } |
| 147 | |
| 148 | $admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME ); |
| 149 | if ( ! $admin_scheme ) { |
| 150 | return $src; |
| 151 | } |
| 152 | |
| 153 | return set_url_scheme( $src, $admin_scheme ); |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0. |
| 158 | * |
| 159 | * @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats). |
| 160 | * @return string Formatted label (always non-empty; "0:00" for zero input). |
| 161 | */ |
| 162 | private static function format_seconds_label( $seconds ) { |
| 163 | $total = (int) floor( max( 0, (float) $seconds ) ); |
| 164 | $hours = (int) floor( $total / 3600 ); |
| 165 | $minutes = (int) floor( ( $total % 3600 ) / 60 ); |
| 166 | $secs = $total % 60; |
| 167 | |
| 168 | if ( $hours > 0 ) { |
| 169 | return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs ); |
| 170 | } |
| 171 | return sprintf( '%d:%02d', $minutes, $secs ); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Resolve the episode cover art URL: episode override โ post featured |
| 176 | * image โ show-level `podcasting_image` option โ empty string. |
| 177 | * |
| 178 | * @param array $attributes Block attributes. |
| 179 | * @param \WP_Post $post Episode post. |
| 180 | * @param string $size Featured-image size to request. |
| 181 | * @return string |
| 182 | */ |
| 183 | private static function resolve_cover_art_url( array $attributes, $post, $size ) { |
| 184 | if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) { |
| 185 | return esc_url_raw( $attributes['coverArt']['url'] ); |
| 186 | } |
| 187 | |
| 188 | $featured_id = (int) get_post_thumbnail_id( $post ); |
| 189 | if ( $featured_id ) { |
| 190 | $featured_url = (string) wp_get_attachment_image_url( $featured_id, $size ); |
| 191 | if ( '' !== $featured_url ) { |
| 192 | return $featured_url; |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | return (string) get_option( 'podcasting_image', '' ); |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Render callback. Full player in every context so the RSS feed carries it |
| 201 | * (how the WPCOM Reader shows it on Atomic/Jetpack). Email uses render_email(). |
| 202 | * |
| 203 | * @param array $attributes Block attributes. |
| 204 | * @param string $content Saved inner content; unused, the block renders its own markup. |
| 205 | * @param \WP_Block $block Parsed block instance, used for post context. |
| 206 | * @return string |
| 207 | */ |
| 208 | public static function render_block( $attributes, $content, $block = null ) { |
| 209 | if ( empty( $attributes['mediaUrl'] ) ) { |
| 210 | return ''; |
| 211 | } |
| 212 | |
| 213 | // Resolve the backing post: block context (Query Loop / singular) first, |
| 214 | // then the global loop. |
| 215 | $post_id = 0; |
| 216 | if ( $block && isset( $block->context['postId'] ) ) { |
| 217 | $post_id = (int) $block->context['postId']; |
| 218 | } |
| 219 | if ( ! $post_id ) { |
| 220 | $post_id = (int) get_the_ID(); |
| 221 | } |
| 222 | if ( ! $post_id ) { |
| 223 | return ''; |
| 224 | } |
| 225 | $post = get_post( $post_id ); |
| 226 | if ( ! $post ) { |
| 227 | return ''; |
| 228 | } |
| 229 | |
| 230 | $media_url = esc_url_raw( $attributes['mediaUrl'] ); |
| 231 | if ( ! wp_http_validate_url( $media_url ) ) { |
| 232 | return ''; |
| 233 | } |
| 234 | |
| 235 | $media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio'; |
| 236 | $mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : ''; |
| 237 | $episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0; |
| 238 | $season_number = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0; |
| 239 | $episode_type = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full'; |
| 240 | $is_explicit = ! empty( $attributes['explicit'] ); |
| 241 | $duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : ''; |
| 242 | $show_poster = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] ); |
| 243 | $transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : ''; |
| 244 | $location_name = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : ''; |
| 245 | $license = isset( $attributes['license'] ) ? (string) $attributes['license'] : ''; |
| 246 | $license_url = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : ''; |
| 247 | $people = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array(); |
| 248 | |
| 249 | $soundbites = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array(); |
| 250 | $alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array(); |
| 251 | |
| 252 | // Drop incomplete rows up front so an all-empty list doesn't enqueue the seek script or emit a bare <ul>. |
| 253 | $soundbites = array_values( |
| 254 | array_filter( |
| 255 | $soundbites, |
| 256 | static function ( $soundbite ) { |
| 257 | return is_array( $soundbite ) && isset( $soundbite['startTime'] ); |
| 258 | } |
| 259 | ) |
| 260 | ); |
| 261 | $alternate_enclosures = array_values( |
| 262 | array_filter( |
| 263 | $alternate_enclosures, |
| 264 | static function ( $alt ) { |
| 265 | if ( ! is_array( $alt ) || empty( $alt['url'] ) ) { |
| 266 | return false; |
| 267 | } |
| 268 | if ( ! wp_http_validate_url( esc_url_raw( (string) $alt['url'] ) ) ) { |
| 269 | return false; |
| 270 | } |
| 271 | // `type` is required per spec โ mirror the feed, which skips typeless entries. |
| 272 | return '' !== ( isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '' ); |
| 273 | } |
| 274 | ) |
| 275 | ); |
| 276 | |
| 277 | // Only ship the click-to-seek script when there are soundbites to wire. |
| 278 | if ( ! empty( $soundbites ) ) { |
| 279 | self::enqueue_view_script(); |
| 280 | } |
| 281 | |
| 282 | if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) { |
| 283 | $transcript_url = ''; |
| 284 | } |
| 285 | |
| 286 | $author_id = (int) $post->post_author; |
| 287 | $title = get_the_title( $post ); |
| 288 | $author_name = get_the_author_meta( 'display_name', $author_id ); |
| 289 | $author_url = esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) ); |
| 290 | $publish_date_iso = get_the_date( 'c', $post ); |
| 291 | $publish_date = get_the_date( '', $post ); |
| 292 | $episode_url = get_permalink( $post ); |
| 293 | $transcript_type = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : ''; |
| 294 | |
| 295 | // Show-level data backs the `partOfSeries` schema reference. |
| 296 | $show_title = (string) get_option( 'podcasting_title', '' ); |
| 297 | $show_image_url = (string) get_option( 'podcasting_image', '' ); |
| 298 | $show_email = (string) get_option( 'podcasting_email', '' ); |
| 299 | |
| 300 | // Resolve cover art unconditionally so schema always carries the image; |
| 301 | // `$show_poster` only gates the visible figure/poster. |
| 302 | $image_url = self::resolve_cover_art_url( $attributes, $post, 'full' ); |
| 303 | |
| 304 | $media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject'; |
| 305 | |
| 306 | $wrapper_attributes = get_block_wrapper_attributes(); |
| 307 | |
| 308 | ob_start(); |
| 309 | ?> |
| 310 | <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>> |
| 311 | <article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode"> |
| 312 | <?php if ( $episode_url ) : ?> |
| 313 | <link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" /> |
| 314 | <?php endif; ?> |
| 315 | <?php if ( $image_url && $show_poster ) : ?> |
| 316 | <figure class="jetpack-podcast-episode__poster"> |
| 317 | <img |
| 318 | src="<?php echo esc_url( $image_url ); ?>" |
| 319 | alt="" |
| 320 | itemprop="image" |
| 321 | loading="lazy" |
| 322 | /> |
| 323 | </figure> |
| 324 | <?php elseif ( $image_url ) : ?> |
| 325 | <meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" /> |
| 326 | <?php endif; ?> |
| 327 | |
| 328 | <div class="jetpack-podcast-episode__body"> |
| 329 | <?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?> |
| 330 | <p class="jetpack-podcast-episode__meta-line"> |
| 331 | <?php if ( $season_number ) : ?> |
| 332 | <span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason"> |
| 333 | <meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" /> |
| 334 | <?php |
| 335 | /* translators: %d: season number. */ |
| 336 | echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) ); |
| 337 | ?> |
| 338 | </span> |
| 339 | <?php endif; ?> |
| 340 | <?php if ( $episode_number ) : ?> |
| 341 | <span class="jetpack-podcast-episode__episode-number"> |
| 342 | <meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" /> |
| 343 | <?php |
| 344 | /* translators: %d: episode number. */ |
| 345 | echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) ); |
| 346 | ?> |
| 347 | </span> |
| 348 | <?php endif; ?> |
| 349 | <?php if ( 'trailer' === $episode_type ) : ?> |
| 350 | <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span> |
| 351 | <?php elseif ( 'bonus' === $episode_type ) : ?> |
| 352 | <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span> |
| 353 | <?php endif; ?> |
| 354 | <?php if ( $is_explicit ) : ?> |
| 355 | <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--explicit" title="<?php esc_attr_e( 'Explicit content', 'jetpack-podcast' ); ?>"><?php echo esc_html( _x( 'E', 'short label for explicit content', 'jetpack-podcast' ) ); ?></span> |
| 356 | <?php endif; ?> |
| 357 | </p> |
| 358 | <?php endif; ?> |
| 359 | |
| 360 | <?php if ( $title ) : ?> |
| 361 | <h3 class="jetpack-podcast-episode__title" itemprop="name"> |
| 362 | <?php if ( $episode_url ) : ?> |
| 363 | <a href="<?php echo esc_url( $episode_url ); ?>"><?php echo esc_html( $title ); ?></a> |
| 364 | <?php else : ?> |
| 365 | <?php echo esc_html( $title ); ?> |
| 366 | <?php endif; ?> |
| 367 | </h3> |
| 368 | <?php endif; ?> |
| 369 | |
| 370 | <?php if ( $author_name || $publish_date || $duration ) : ?> |
| 371 | <p class="jetpack-podcast-episode__byline"> |
| 372 | <?php if ( $author_name ) : ?> |
| 373 | <span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person"> |
| 374 | <?php if ( $author_url ) : ?> |
| 375 | <a href="<?php echo esc_url( $author_url ); ?>" itemprop="url"> |
| 376 | <span itemprop="name"><?php echo esc_html( $author_name ); ?></span> |
| 377 | </a> |
| 378 | <?php else : ?> |
| 379 | <span itemprop="name"><?php echo esc_html( $author_name ); ?></span> |
| 380 | <?php endif; ?> |
| 381 | </span> |
| 382 | <?php endif; ?> |
| 383 | <?php if ( $publish_date ) : ?> |
| 384 | <time |
| 385 | class="jetpack-podcast-episode__date" |
| 386 | datetime="<?php echo esc_attr( $publish_date_iso ); ?>" |
| 387 | itemprop="datePublished" |
| 388 | > |
| 389 | <?php echo esc_html( $publish_date ); ?> |
| 390 | </time> |
| 391 | <?php endif; ?> |
| 392 | <?php if ( $duration ) : ?> |
| 393 | <span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span> |
| 394 | <?php endif; ?> |
| 395 | </p> |
| 396 | <?php endif; ?> |
| 397 | |
| 398 | <div |
| 399 | class="jetpack-podcast-episode__player" |
| 400 | itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>" |
| 401 | itemscope |
| 402 | itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>" |
| 403 | > |
| 404 | <meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" /> |
| 405 | <?php if ( $mime_type ) : ?> |
| 406 | <meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" /> |
| 407 | <?php endif; ?> |
| 408 | <?php if ( $duration ) : ?> |
| 409 | <meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" /> |
| 410 | <?php endif; ?> |
| 411 | <?php if ( 'video' === $media_type ) : ?> |
| 412 | <video |
| 413 | class="jetpack-podcast-episode__video" |
| 414 | controls |
| 415 | preload="none" |
| 416 | src="<?php echo esc_url( $media_url ); ?>" |
| 417 | <?php |
| 418 | if ( $image_url && $show_poster ) : |
| 419 | ?> |
| 420 | poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?> |
| 421 | <?php |
| 422 | if ( $mime_type ) : |
| 423 | ?> |
| 424 | data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?> |
| 425 | ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Watch the episode', 'jetpack-podcast' ); ?></a></video> |
| 426 | <?php else : ?> |
| 427 | <audio |
| 428 | class="jetpack-podcast-episode__audio" |
| 429 | controls |
| 430 | preload="none" |
| 431 | src="<?php echo esc_url( $media_url ); ?>" |
| 432 | <?php |
| 433 | if ( $mime_type ) : |
| 434 | ?> |
| 435 | data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?> |
| 436 | ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Listen to the episode', 'jetpack-podcast' ); ?></a></audio> |
| 437 | <?php endif; ?> |
| 438 | </div> |
| 439 | |
| 440 | <?php if ( ! empty( $soundbites ) ) : ?> |
| 441 | <ul class="jetpack-podcast-episode__soundbites"> |
| 442 | <?php |
| 443 | foreach ( $soundbites as $soundbite ) : |
| 444 | if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) { |
| 445 | continue; |
| 446 | } |
| 447 | $start_label = self::format_seconds_label( $soundbite['startTime'] ); |
| 448 | $soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : ''; |
| 449 | // Keep fractional offsets so the seek button and Clip metadata match the feed. |
| 450 | $start_seconds = max( 0, (float) $soundbite['startTime'] ); |
| 451 | $end_seconds = isset( $soundbite['duration'] ) |
| 452 | ? $start_seconds + max( 0, (float) $soundbite['duration'] ) |
| 453 | : null; |
| 454 | ?> |
| 455 | <li |
| 456 | class="jetpack-podcast-episode__soundbite" |
| 457 | itemprop="hasPart" |
| 458 | itemscope |
| 459 | itemtype="https://schema.org/Clip" |
| 460 | > |
| 461 | <meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" /> |
| 462 | <?php if ( null !== $end_seconds ) : ?> |
| 463 | <meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" /> |
| 464 | <?php endif; ?> |
| 465 | <button |
| 466 | type="button" |
| 467 | class="jetpack-podcast-episode__soundbite-button" |
| 468 | data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>" |
| 469 | > |
| 470 | <time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time> |
| 471 | <?php if ( '' !== $soundbite_title ) : ?> |
| 472 | <span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span> |
| 473 | <?php endif; ?> |
| 474 | </button> |
| 475 | </li> |
| 476 | <?php endforeach; ?> |
| 477 | </ul> |
| 478 | <?php endif; ?> |
| 479 | |
| 480 | <?php if ( ! empty( $alternate_enclosures ) ) : ?> |
| 481 | <ul class="jetpack-podcast-episode__alternates"> |
| 482 | <?php |
| 483 | foreach ( $alternate_enclosures as $alt ) : |
| 484 | if ( ! is_array( $alt ) || empty( $alt['url'] ) ) { |
| 485 | continue; |
| 486 | } |
| 487 | $alt_url = esc_url_raw( (string) $alt['url'] ); |
| 488 | if ( ! wp_http_validate_url( $alt_url ) ) { |
| 489 | continue; |
| 490 | } |
| 491 | $alt_type = isset( $alt['type'] ) ? (string) $alt['type'] : ''; |
| 492 | $alt_title = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : ''; |
| 493 | $alt_lang = isset( $alt['lang'] ) ? (string) $alt['lang'] : ''; |
| 494 | $alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0; |
| 495 | $details = array(); |
| 496 | if ( '' !== $alt_lang ) { |
| 497 | $details[] = $alt_lang; |
| 498 | } |
| 499 | if ( $alt_bitrate > 0 ) { |
| 500 | $details[] = sprintf( |
| 501 | /* translators: %d: bitrate in kilobits per second. */ |
| 502 | __( '%d kbps', 'jetpack-podcast' ), |
| 503 | (int) round( $alt_bitrate / 1000 ) |
| 504 | ); |
| 505 | } |
| 506 | if ( '' !== $alt_type ) { |
| 507 | $details[] = $alt_type; |
| 508 | } |
| 509 | $details_label = $details ? ' (' . implode( ', ', $details ) . ')' : ''; |
| 510 | $display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' ); |
| 511 | ?> |
| 512 | <li class="jetpack-podcast-episode__alternate"> |
| 513 | <a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>> |
| 514 | <?php echo esc_html( $display_label . $details_label ); ?> |
| 515 | </a> |
| 516 | </li> |
| 517 | <?php endforeach; ?> |
| 518 | </ul> |
| 519 | <?php endif; ?> |
| 520 | |
| 521 | <?php if ( ! empty( $people ) ) : ?> |
| 522 | <ul class="jetpack-podcast-episode__people"> |
| 523 | <?php |
| 524 | foreach ( $people as $person ) : |
| 525 | if ( ! is_array( $person ) || empty( $person['name'] ) ) { |
| 526 | continue; |
| 527 | } |
| 528 | $person_name = (string) $person['name']; |
| 529 | $person_role = isset( $person['role'] ) ? (string) $person['role'] : ''; |
| 530 | $person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : ''; |
| 531 | $person_img = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : ''; |
| 532 | ?> |
| 533 | <li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person"> |
| 534 | <?php if ( $person_img ) : ?> |
| 535 | <img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" /> |
| 536 | <?php endif; ?> |
| 537 | <?php if ( $person_href ) : ?> |
| 538 | <a href="<?php echo esc_url( $person_href ); ?>" itemprop="url"> |
| 539 | <span itemprop="name"><?php echo esc_html( $person_name ); ?></span> |
| 540 | </a> |
| 541 | <?php else : ?> |
| 542 | <span itemprop="name"><?php echo esc_html( $person_name ); ?></span> |
| 543 | <?php endif; ?> |
| 544 | <?php if ( $person_role ) : ?> |
| 545 | <span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span> |
| 546 | <?php endif; ?> |
| 547 | </li> |
| 548 | <?php endforeach; ?> |
| 549 | </ul> |
| 550 | <?php endif; ?> |
| 551 | |
| 552 | <?php if ( '' !== $show_title ) : ?> |
| 553 | <div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries"> |
| 554 | <meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" /> |
| 555 | <?php if ( $show_image_url ) : ?> |
| 556 | <meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" /> |
| 557 | <?php endif; ?> |
| 558 | <?php if ( $show_email ) : ?> |
| 559 | <span itemprop="publisher" itemscope itemtype="https://schema.org/Organization"> |
| 560 | <meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" /> |
| 561 | </span> |
| 562 | <?php endif; ?> |
| 563 | </div> |
| 564 | <?php endif; ?> |
| 565 | |
| 566 | <?php if ( $transcript_url || $location_name || $license ) : ?> |
| 567 | <ul class="jetpack-podcast-episode__links"> |
| 568 | <?php if ( $transcript_url ) : ?> |
| 569 | <li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject"> |
| 570 | <meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" /> |
| 571 | <?php if ( '' !== $transcript_type ) : ?> |
| 572 | <meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" /> |
| 573 | <?php endif; ?> |
| 574 | <a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link"> |
| 575 | <?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?> |
| 576 | </a> |
| 577 | </li> |
| 578 | <?php endif; ?> |
| 579 | <?php if ( $location_name ) : ?> |
| 580 | <li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li> |
| 581 | <?php endif; ?> |
| 582 | <?php if ( $license ) : ?> |
| 583 | <li class="jetpack-podcast-episode__license"> |
| 584 | <?php |
| 585 | /* translators: %s: license identifier (e.g. "CC-BY-4.0"). */ |
| 586 | $license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license ); |
| 587 | ?> |
| 588 | <?php if ( $license_url ) : ?> |
| 589 | <a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a> |
| 590 | <?php else : ?> |
| 591 | <?php echo esc_html( $license_label ); ?> |
| 592 | <?php endif; ?> |
| 593 | </li> |
| 594 | <?php endif; ?> |
| 595 | </ul> |
| 596 | <?php endif; ?> |
| 597 | </div> |
| 598 | </article> |
| 599 | </div> |
| 600 | <?php |
| 601 | |
| 602 | return ob_get_clean(); |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Render the block for email (WooCommerce Email Editor). Email can't run the |
| 607 | * player, so render a static card linking back to the episode post. |
| 608 | * |
| 609 | * @param string $block_content The original block HTML content. |
| 610 | * @param array $parsed_block The parsed block data including attributes. |
| 611 | * @param object $rendering_context Email rendering context. |
| 612 | * @return string |
| 613 | */ |
| 614 | public static function render_email( $block_content, array $parsed_block, $rendering_context ) { |
| 615 | if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) || |
| 616 | ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) || |
| 617 | ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) { |
| 618 | return ''; |
| 619 | } |
| 620 | |
| 621 | $attrs = $parsed_block['attrs']; |
| 622 | |
| 623 | if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) { |
| 624 | return ''; |
| 625 | } |
| 626 | |
| 627 | $post = get_post(); |
| 628 | if ( ! $post ) { |
| 629 | return ''; |
| 630 | } |
| 631 | |
| 632 | $post_url = get_permalink( $post ); |
| 633 | if ( empty( $post_url ) ) { |
| 634 | return ''; |
| 635 | } |
| 636 | |
| 637 | $title = get_the_title( $post ); |
| 638 | $author_name = get_the_author_meta( 'display_name', (int) $post->post_author ); |
| 639 | $publish_date = get_the_date( '', $post ); |
| 640 | $duration = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : ''; |
| 641 | $season_number = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0; |
| 642 | $episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0; |
| 643 | |
| 644 | $image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' ); |
| 645 | if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) { |
| 646 | $image_url = ''; |
| 647 | } |
| 648 | |
| 649 | $cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType'] |
| 650 | ? __( 'Watch the episode', 'jetpack-podcast' ) |
| 651 | : __( 'Listen to the episode', 'jetpack-podcast' ); |
| 652 | |
| 653 | $meta_parts = array(); |
| 654 | if ( $season_number ) { |
| 655 | /* translators: %d: season number. */ |
| 656 | $meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ); |
| 657 | } |
| 658 | if ( $episode_number ) { |
| 659 | /* translators: %d: episode number. */ |
| 660 | $meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ); |
| 661 | } |
| 662 | |
| 663 | $byline_parts = array_filter( array( $author_name, $publish_date, $duration ) ); |
| 664 | |
| 665 | $body = ''; |
| 666 | if ( $meta_parts ) { |
| 667 | $body .= sprintf( |
| 668 | '<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>', |
| 669 | esc_html( implode( ' ยท ', $meta_parts ) ) |
| 670 | ); |
| 671 | } |
| 672 | if ( $title ) { |
| 673 | $body .= sprintf( |
| 674 | '<h3 style="margin: 0 0 4px; font-size: 18px; line-height: 1.3;"><a href="%s" style="color: inherit; text-decoration: none;">%s</a></h3>', |
| 675 | esc_url( $post_url ), |
| 676 | esc_html( $title ) |
| 677 | ); |
| 678 | } |
| 679 | if ( $byline_parts ) { |
| 680 | $body .= sprintf( |
| 681 | '<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>', |
| 682 | esc_html( implode( ' ยท ', $byline_parts ) ) |
| 683 | ); |
| 684 | } |
| 685 | $body .= sprintf( |
| 686 | '<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">▶ %s</a></p>', |
| 687 | esc_url( $post_url ), |
| 688 | esc_html( $cta_label ) |
| 689 | ); |
| 690 | |
| 691 | $cells = ''; |
| 692 | if ( '' !== $image_url ) { |
| 693 | $image_link = sprintf( |
| 694 | '<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>', |
| 695 | esc_url( $post_url ), |
| 696 | esc_url( $image_url ) |
| 697 | ); |
| 698 | |
| 699 | // Padding on an inner wrapper, not the cell: the mobile media query |
| 700 | // zeroes `.layout-flex-item` padding when the card stacks. |
| 701 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 702 | $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell( |
| 703 | '<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>', |
| 704 | array( |
| 705 | 'class' => 'layout-flex-item', |
| 706 | 'width' => '96', |
| 707 | 'valign' => 'top', |
| 708 | 'style' => 'width: 96px;', |
| 709 | ) |
| 710 | ); |
| 711 | } |
| 712 | |
| 713 | // Table-wrap the body so bare <p>/<h3> survive email pipelines (core |
| 714 | // blocks do the same); padding rides the nested cell, safe when stacked. |
| 715 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 716 | $body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( |
| 717 | $body, |
| 718 | array( 'style' => 'width: 100%; border-collapse: collapse;' ), |
| 719 | array( 'style' => 'padding: 16px;' ) |
| 720 | ); |
| 721 | |
| 722 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 723 | $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell( |
| 724 | $body_table, |
| 725 | array( |
| 726 | 'class' => 'layout-flex-item', |
| 727 | 'valign' => 'top', |
| 728 | ) |
| 729 | ); |
| 730 | |
| 731 | // Cap to the email layout width when the context exposes it. |
| 732 | $target_width = 600; |
| 733 | if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) { |
| 734 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 735 | $layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() ); |
| 736 | if ( $layout_width > 0 ) { |
| 737 | $target_width = $layout_width; |
| 738 | } |
| 739 | } |
| 740 | |
| 741 | // Preserve the vertical gap from email_attrs (the engine sets margin-top). |
| 742 | $email_attrs = $parsed_block['email_attrs'] ?? array(); |
| 743 | $table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' ); |
| 744 | |
| 745 | // `layout-flex-wrapper` opts into the engine's mobile stacking (collapses |
| 746 | // under 660px). border-collapse must stay `separate` for the rounded border. |
| 747 | $table_style = sprintf( |
| 748 | '%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;', |
| 749 | $table_margin_style ? $table_margin_style : 'margin: 16px 0;', |
| 750 | $target_width |
| 751 | ); |
| 752 | |
| 753 | // Append user block supports so editor styling overrides the defaults. |
| 754 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 755 | $user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) ); |
| 756 | if ( ! empty( $user_styles['css'] ) ) { |
| 757 | $table_style .= ' ' . $user_styles['css']; |
| 758 | } |
| 759 | |
| 760 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above. |
| 761 | return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( |
| 762 | $cells, |
| 763 | array( |
| 764 | 'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper', |
| 765 | 'style' => $table_style, |
| 766 | ), |
| 767 | array(), |
| 768 | array(), |
| 769 | false |
| 770 | ); |
| 771 | } |
| 772 | } |