Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 145
0.00% covered (danger)
0.00%
0 / 7
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Podcast_Player\render_error
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
Automattic\Jetpack\Extensions\Podcast_Player\render_block_implementation
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
Automattic\Jetpack\Extensions\Podcast_Player\render_player
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
72
Automattic\Jetpack\Extensions\Podcast_Player\get_colors
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
Automattic\Jetpack\Extensions\Podcast_Player\get_css_vars
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
Automattic\Jetpack\Extensions\Podcast_Player\render
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
Automattic\Jetpack\Extensions\Podcast_Player\render_email_implementation
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2/**
3 * Podcast Player block render implementation.
4 *
5 * Loaded lazily from podcast-player.php only when the block is rendered, to keep
6 * the render body out of the eager front-end PHP/opcache footprint.
7 *
8 * @package automattic/jetpack
9 */
10
11namespace Automattic\Jetpack\Extensions\Podcast_Player;
12
13use Automattic\Jetpack\Blocks;
14use Automattic\Jetpack\Status\Request;
15use Jetpack_Gutenberg;
16use Jetpack_Podcast_Helper;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22if ( ! class_exists( 'Jetpack_Podcast_Helper' ) ) {
23    require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-helper.php';
24}
25
26/**
27 * Returns the error message wrapped in HTML if current user
28 * has the capability to edit the post. Public visitors will
29 * never see errors.
30 *
31 * @param string $message The error message to display.
32 * @return string
33 */
34function render_error( $message ) {
35    // Suppress errors for users unable to address them.
36    if ( ! current_user_can( 'edit_posts' ) ) {
37        return '';
38    }
39    return '<p>' . esc_html( $message ) . '</p>';
40}
41
42/**
43 * Podcast Player block registration/dependency declaration.
44 *
45 * @param array  $attributes Array containing the Podcast Player block attributes.
46 * @param string $content    Fallback content - a direct link to RSS, as rendered by save.js.
47 * @return string
48 */
49function render_block_implementation( $attributes, $content ) {
50    // Don't render an interactive version of the block outside the frontend context.
51    if ( ! Request::is_frontend() ) {
52        return $content;
53    }
54
55    // Test for empty URLS.
56    if ( empty( $attributes['url'] ) ) {
57        return render_error( __( 'No Podcast URL provided. Please enter a valid Podcast RSS feed URL.', 'jetpack' ) );
58    }
59
60    // Test for invalid URLs.
61    if ( ! wp_http_validate_url( $attributes['url'] ) ) {
62        return render_error( __( 'Your podcast URL is invalid and couldn\'t be embedded. Please double check your URL.', 'jetpack' ) );
63    }
64
65    if ( ! empty( $attributes['selectedEpisodes'] ) ) {
66        $guids       = array_map(
67            function ( $episode ) {
68                return $episode['guid'];
69            },
70            $attributes['selectedEpisodes']
71        );
72        $player_args = array( 'guids' => $guids );
73    } else {
74        $player_args = array();
75    }
76
77    // Sanitize the URL.
78    $attributes['url'] = esc_url_raw( $attributes['url'] );
79    $player_data       = ( new Jetpack_Podcast_Helper( $attributes['url'] ) )->get_player_data( $player_args );
80
81    if ( is_wp_error( $player_data ) ) {
82        return render_error( $player_data->get_error_message() );
83    }
84
85    return render_player( $player_data, $attributes );
86}
87
88/**
89 * Renders the HTML for the Podcast player and tracklist.
90 *
91 * @param array $player_data The player data details.
92 * @param array $attributes Array containing the Podcast Player block attributes.
93 * @return string The HTML for the podcast player.
94 */
95function render_player( $player_data, $attributes ) {
96    // If there are no tracks (it is possible) then display appropriate user facing error message.
97    if ( empty( $player_data['tracks'] ) ) {
98        return render_error( __( 'No tracks available to play.', 'jetpack' ) );
99    }
100
101    if ( is_wp_error( $player_data['tracks'] ) ) {
102        return render_error( $player_data['tracks']->get_error_message() );
103    }
104
105    // Only use the amount of tracks requested.
106    $player_data['tracks'] = array_slice(
107        $player_data['tracks'],
108        0,
109        absint( $attributes['itemsToShow'] )
110    );
111
112    // Generate a unique id for the block instance.
113    $instance_id             = wp_unique_id( 'jetpack-podcast-player-block-' . get_the_ID() . '-' );
114    $player_data['playerId'] = $instance_id;
115
116    // Generate object to be used as props for PodcastPlayer.
117    $player_props = array_merge(
118        // Add all attributes.
119        array( 'attributes' => $attributes ),
120        // Add all player data.
121        $player_data
122    );
123
124    $primary_colors    = get_colors( 'primary', $attributes, 'color' );
125    $secondary_colors  = get_colors( 'secondary', $attributes, 'color' );
126    $background_colors = get_colors( 'background', $attributes, 'background-color' );
127
128    $player_classes_name  = trim( "{$secondary_colors['class']} {$background_colors['class']}" );
129    $player_inline_style  = trim( "{$secondary_colors['style']} {$background_colors['style']}" );
130    $player_inline_style .= get_css_vars( $attributes );
131    $wrapper_attributes   = \WP_Block_Supports::get_instance()->apply_block_supports();
132    $block_classname      = Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attributes, array( 'is-default' ) );
133    $is_amp               = Blocks::is_amp_request();
134
135    ob_start();
136    ?>
137    <div class="<?php echo esc_attr( $block_classname ); ?>"<?php echo ! empty( $wrapper_attributes['style'] ) ? ' style="' . esc_attr( $wrapper_attributes['style'] ) . '"' : ''; ?> id="<?php echo esc_attr( $instance_id ); ?>">
138        <section
139            class="jetpack-podcast-player <?php echo esc_attr( $player_classes_name ); ?>"
140            style="<?php echo esc_attr( $player_inline_style ); ?>"
141        >
142            <?php
143            render(
144                'podcast-header',
145                array_merge(
146                    $player_props,
147                    array(
148                        'primary_colors' => $primary_colors,
149                        'player_id'      => $player_data['playerId'],
150                    )
151                )
152            );
153            ?>
154            <?php if ( count( $player_data['tracks'] ) > 1 ) : ?>
155            <ol class="jetpack-podcast-player__tracks">
156                <?php foreach ( $player_data['tracks'] as $track_index => $attachment ) : ?>
157                    <?php
158                    render(
159                        'playlist-track',
160                        array(
161                            'is_active'        => 0 === $track_index,
162                            'attachment'       => $attachment,
163                            'primary_colors'   => $primary_colors,
164                            'secondary_colors' => $secondary_colors,
165                        )
166                    );
167                    ?>
168                <?php endforeach; ?>
169            </ol>
170            <?php endif; ?>
171        </section>
172        <?php if ( ! $is_amp ) : ?>
173        <script type="application/json"><?php echo wp_json_encode( $player_props, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?></script>
174        <?php endif; ?>
175    </div>
176    <?php
177    /**
178     * Enqueue necessary scripts and styles.
179     */
180    if ( ! $is_amp ) {
181        wp_enqueue_style( 'wp-mediaelement' );
182    }
183    Jetpack_Gutenberg::load_assets_as_required( __DIR__, array( 'mediaelement' ) );
184
185    return ob_get_clean();
186}
187
188/**
189 * Given the color name, block attributes and the CSS property,
190 * the function will return an array with the `class` and `style`
191 * HTML attributes to be used straight in the markup.
192 *
193 * @example
194 * $color = get_colors( 'secondary', $attributes, 'border-color'
195 *  => array( 'class' => 'has-secondary', 'style' => 'border-color: #333' )
196 *
197 * @param string $name     Color attribute name, for instance `primary`, `secondary`, ...
198 * @param array  $attrs    Block attributes.
199 * @param string $property Color CSS property, fo instance `color`, `background-color`, ...
200 * @return array           Colors array.
201 */
202function get_colors( $name, $attrs, $property ) {
203    $attr_color  = "{$name}Color";
204    $attr_custom = 'custom' . ucfirst( $attr_color );
205
206    $color        = $attrs[ $attr_color ] ?? null;
207    $custom_color = $attrs[ $attr_custom ] ?? null;
208
209    $colors = array(
210        'class' => '',
211        'style' => '',
212    );
213
214    if ( $color || $custom_color ) {
215        $colors['class'] .= "has-{$name}";
216
217        if ( $color ) {
218            $colors['class'] .= " has-{$color}-{$property}";
219        } elseif ( $custom_color ) {
220            $colors['style'] .= "{$property}{$custom_color};";
221        }
222    }
223
224    return $colors;
225}
226
227/**
228 * It generates a string with CSS variables according to the
229 * block colors, prefixing each one with `--jetpack-podcast-player'.
230 *
231 * @param array $attrs Podcast Block attributes object.
232 * @return string      CSS variables depending on block colors.
233 */
234function get_css_vars( $attrs ) {
235    $colors_name = array( 'primary', 'secondary', 'background' );
236
237    $inline_style = '';
238    foreach ( $colors_name as $color ) {
239        $hex_color = 'hex' . ucfirst( $color ) . 'Color';
240        if ( ! empty( $attrs[ $hex_color ] ) ) {
241            $inline_style .= " --jetpack-podcast-player-{$color}{$attrs[ $hex_color ]};";
242        }
243    }
244    return $inline_style;
245}
246
247/**
248 * Render the given template in server-side.
249 * Important note:
250 *    The $template_props array will be extracted.
251 *    This means it will create a var for each array item.
252 *    Keep it mind when using this param to pass
253 *    properties to the template.
254 *
255 * @html-template-var array $template_props
256 *
257 * @param string $name           Template name, available in `./templates` folder.
258 * @param array  $template_props Template properties. Optional.
259 * @param bool   $print          Render template. True as default.
260 * @return string|null           HTML markup or null.
261 */
262function render( $name, $template_props = array(), $print = true ) {
263    if ( ! strpos( $name, '.php' ) ) {
264        $name .= '.php';
265    }
266
267    $template_path = __DIR__ . '/templates/' . $name;
268
269    if ( ! file_exists( $template_path ) ) {
270        return '';
271    }
272
273    if ( $print ) {
274        include $template_path;
275    } else {
276        ob_start();
277        include $template_path;
278        $markup = ob_get_contents();
279        ob_end_clean();
280
281        return $markup;
282    }
283}
284
285/**
286 * Render podcast player block for email.
287 *
288 * @param string $block_content     The original block HTML content.
289 * @param array  $parsed_block      The parsed block data including attributes.
290 * @param object $rendering_context Email rendering context.
291 *
292 * @return string
293 */
294function render_email_implementation( $block_content, array $parsed_block, $rendering_context ) {
295    // Validate input parameters and required dependencies
296    if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
297        ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Audio' ) ) {
298        return '';
299    }
300
301    $attr = $parsed_block['attrs'];
302
303    // Check if we have a valid podcast URL
304    if ( empty( $attr['url'] ) || ! wp_http_validate_url( $attr['url'] ) ) {
305        return '';
306    }
307
308    /*
309     * Link to the post containing the podcast player for better UX
310     * Users can see the full context and interact with the full player on the site
311     */
312    $post_url = get_the_permalink();
313
314    if ( empty( $post_url ) ) {
315        return '';
316    }
317
318    /*
319     * Build block content HTML with audio tag that the audio renderer expects
320     * Note: The audio renderer extracts the URL from the src attribute and uses it as a link,
321     * not as an actual audio source. While semantically incorrect to use a post URL in an
322     * audio src attribute, this is the expected format for the WooCommerce audio renderer.
323     * The renderer will create a clickable link to the post, not attempt to play audio.
324     */
325    $escaped_post_url   = esc_url( $post_url );
326    $block_content_html = sprintf( '<audio src="%s"></audio>', $escaped_post_url );
327
328    // Create a mock parsed block that WooCommerce's audio renderer can handle
329    $mock_parsed_block = array(
330        'attrs' => array(
331            'src'   => $escaped_post_url,
332            'label' => __( 'Listen to the podcast', 'jetpack' ),
333        ),
334    );
335
336    // Preserve email_attrs if present (used for spacing)
337    if ( ! empty( $parsed_block['email_attrs'] ) ) {
338        $mock_parsed_block['email_attrs'] = $parsed_block['email_attrs'];
339    }
340
341    // Use WooCommerce's core audio renderer
342    $woo_audio_renderer = new \Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Audio();
343
344    return $woo_audio_renderer->render( $block_content_html, $mock_parsed_block, $rendering_context );
345}