Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.43% covered (success)
98.43%
250 / 254
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Blocks
98.43% covered (success)
98.43%
250 / 254
81.82% covered (warning)
81.82%
9 / 11
110
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 enqueue_editor_data
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 flag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 heading_level
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 render_channel_details
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 channel_item
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
10
 block_video
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 render_video_details
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
24.08
 episode_label
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 render_playlist_details
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
24
 render_episode_navigation
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
10.02
1<?php
2/**
3 * The channel blocks: channel details, video details, playlist details and episode navigation.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Post;
11
12/**
13 * Registers and renders the server-side blocks a VideoPress channel theme
14 * builds its pages from, on sites with the channel feature enabled. Each renders
15 * nothing when it has nothing to show. Also resolves the Video Playlist block's
16 * `source` ( a playlist published on the site ) on any theme.
17 */
18class Channel_Blocks {
19
20    /**
21     * Blocks, by name, with their render method.
22     *
23     * @var array<string, string>
24     */
25    const BLOCKS = array(
26        'videopress/channel-details'    => 'render_channel_details',
27        'videopress/video-details'      => 'render_video_details',
28        'videopress/playlist-details'   => 'render_playlist_details',
29        'videopress/episode-navigation' => 'render_episode_navigation',
30    );
31
32    /**
33     * Channel details items, the first ones being the default selection.
34     *
35     * @var string[]
36     */
37    const CHANNEL_ITEMS         = array( 'handle', 'subscribers', 'videos', 'playlists', 'views', 'joined' );
38    const DEFAULT_CHANNEL_ITEMS = array( 'handle', 'subscribers', 'videos', 'playlists' );
39
40    /**
41     * Register the Video Playlist block's `source` on any theme, and the blocks where the channel
42     * feature is enabled ( see Channel::is_enabled() ). Like the All Playlists block, only where
43     * the Video Playlist block is.
44     *
45     * @param string|null $build_dir Directory holding each block's block.json; tests can point it at fixtures.
46     *
47     * @return void
48     */
49    public static function register( $build_dir = null ) {
50        if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( Playlist_Index::BLOCK_NAME ) ) {
51            return;
52        }
53        if ( ! has_filter( 'render_block_data', array( Channel::class, 'playlist_source_block' ) ) ) {
54            add_filter( 'render_block_data', array( Channel::class, 'playlist_source_block' ) );
55        }
56        if ( null === $build_dir ) {
57            $build_dir = __DIR__ . '/../build/block-editor/blocks';
58        }
59
60        foreach ( Channel::is_enabled() ? self::BLOCKS : array() as $name => $method ) {
61            $metadata_file = $build_dir . '/' . substr( $name, strlen( 'videopress/' ) ) . '/block.json';
62            if ( ! file_exists( $metadata_file ) || \WP_Block_Type_Registry::get_instance()->is_registered( $name ) ) {
63                continue;
64            }
65            register_block_type( $metadata_file, array( 'render_callback' => array( __CLASS__, $method ) ) );
66        }
67
68        if ( ! has_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_editor_data' ) ) ) {
69            add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_editor_data' ) );
70        }
71    }
72
73    /**
74     * Hand the editor the site's playlists, most recently updated first, for the playlist pickers
75     * of the blocks that show one and for their canvas previews. An inline script rather than a
76     * REST route, so WordPress.com Simple's editor gets it too.
77     *
78     * @return void
79     */
80    public static function enqueue_editor_data() {
81        if ( ! current_user_can( 'edit_posts' ) ) {
82            return;
83        }
84        $handle = '';
85        foreach ( array( Playlist_Index::BLOCK_NAME, 'videopress/playlist-details' ) as $name ) {
86            $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( $name );
87            if ( $block_type && ! empty( $block_type->editor_script_handles ) ) {
88                $handle = $block_type->editor_script_handles[0];
89                break;
90            }
91        }
92        if ( '' === $handle ) {
93            return;
94        }
95
96        $playlists = array();
97        foreach ( Channel::playlists() as $playlist ) {
98            $playlists[] = array(
99                'key'    => $playlist['key'],
100                'title'  => $playlist['title'],
101                'videos' => $playlist['videos'],
102            );
103        }
104        wp_add_inline_script(
105            $handle,
106            'window.videopressChannelEditor = ' . wp_json_encode( array( 'playlists' => $playlists ), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';',
107            'before'
108        );
109    }
110
111    /**
112     * A boolean attribute.
113     *
114     * @param array  $attributes    Block attributes.
115     * @param string $key           Attribute.
116     * @param bool   $default_value Value when unset.
117     *
118     * @return bool
119     */
120    private static function flag( $attributes, $key, $default_value ) {
121        return isset( $attributes[ $key ] ) ? (bool) $attributes[ $key ] : $default_value;
122    }
123
124    /**
125     * A heading level attribute, 1 to 6; anything else ( 0 = automatic ) picks the default.
126     *
127     * @param array $attributes    Block attributes.
128     * @param int   $default_value Level when unset.
129     *
130     * @return int
131     */
132    private static function heading_level( $attributes, $default_value ) {
133        $level = isset( $attributes['headingLevel'] ) && is_numeric( $attributes['headingLevel'] ) ? (int) $attributes['headingLevel'] : 0;
134        return $level >= 1 && $level <= 6 ? $level : $default_value;
135    }
136
137    /**
138     * Channel details: the handle, subscribers, videos, playlists, views and join date.
139     *
140     * @param array $attributes Block attributes.
141     *
142     * @return string
143     */
144    public static function render_channel_details( $attributes ) {
145        $attributes = is_array( $attributes ) ? $attributes : array();
146        $items      = isset( $attributes['items'] ) && is_array( $attributes['items'] )
147            ? array_values( array_intersect( array_unique( $attributes['items'] ), self::CHANNEL_ITEMS ) )
148            : self::DEFAULT_CHANNEL_ITEMS;
149        $layout     = isset( $attributes['layout'] ) && 'list' === $attributes['layout'] ? 'list' : 'inline';
150
151        $markup = '';
152        foreach ( $items as $item ) {
153            $value = self::channel_item( $item );
154            if ( null === $value ) {
155                continue;
156            }
157            $markup .= 'list' === $layout
158                ? sprintf(
159                    '<div class="videopress-channel-details__item is-%1$s"><dt>%2$s</dt><dd>%3$s</dd></div>',
160                    esc_attr( $item ),
161                    esc_html( $value['label'] ),
162                    esc_html( $value['value'] )
163                )
164                : sprintf(
165                    '<span class="videopress-channel-details__item is-%1$s">%2$s</span>',
166                    esc_attr( $item ),
167                    esc_html( $value['inline'] )
168                );
169        }
170        if ( '' === $markup ) {
171            return '';
172        }
173
174        $tag = 'list' === $layout ? 'dl' : 'div';
175        return sprintf(
176            '<%1$s %2$s>%3$s</%1$s>',
177            $tag,
178            get_block_wrapper_attributes( array( 'class' => 'videopress-channel-details is-layout-' . $layout ) ),
179            $markup
180        );
181    }
182
183    /**
184     * One channel details item, or null when its value is unknown.
185     *
186     * @param string $item One of CHANNEL_ITEMS.
187     *
188     * @return array{label: string, value: string, inline: string}|null
189     */
190    private static function channel_item( $item ) {
191        switch ( $item ) {
192            case 'handle':
193                $handle = Channel_Data::handle();
194                return '' === $handle ? null : array(
195                    'label'  => __( 'Handle', 'jetpack-videopress-pkg' ),
196                    'value'  => $handle,
197                    'inline' => $handle,
198                );
199            case 'subscribers':
200                $count = Channel_Data::subscriber_count();
201                if ( null === $count ) {
202                    return null;
203                }
204                return array(
205                    'label'  => __( 'Subscribers', 'jetpack-videopress-pkg' ),
206                    'value'  => Channel_Data::compact_number( $count ),
207                    /* translators: %s: number of subscribers, e.g. "48K". */
208                    'inline' => sprintf( _n( '%s subscriber', '%s subscribers', $count, 'jetpack-videopress-pkg' ), Channel_Data::compact_number( $count ) ),
209                );
210            case 'videos':
211                $count = Channel_Data::video_count();
212                return array(
213                    'label'  => __( 'Videos', 'jetpack-videopress-pkg' ),
214                    'value'  => number_format_i18n( $count ),
215                    /* translators: %s: number of videos. */
216                    'inline' => sprintf( _n( '%s video', '%s videos', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) ),
217                );
218            case 'playlists':
219                $count = count( Channel::playlists() );
220                return array(
221                    'label'  => __( 'Playlists', 'jetpack-videopress-pkg' ),
222                    'value'  => number_format_i18n( $count ),
223                    /* translators: %s: number of playlists. */
224                    'inline' => sprintf( _n( '%s playlist', '%s playlists', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) ),
225                );
226            case 'views':
227                $count = Channel_Data::total_views();
228                return array(
229                    'label'  => __( 'Total views', 'jetpack-videopress-pkg' ),
230                    'value'  => Channel_Data::compact_number( $count ),
231                    /* translators: %s: number of views, e.g. "1.2M". */
232                    'inline' => sprintf( _n( '%s view', '%s views', $count, 'jetpack-videopress-pkg' ), Channel_Data::compact_number( $count ) ),
233                );
234            default: // 'joined', the last of CHANNEL_ITEMS.
235                $time = Channel_Data::joined();
236                if ( ! $time ) {
237                    return null;
238                }
239                $date = date_i18n( get_option( 'date_format' ), $time );
240                return array(
241                    'label'  => __( 'Joined', 'jetpack-videopress-pkg' ),
242                    'value'  => $date,
243                    /* translators: %s: date the channel started. */
244                    'inline' => sprintf( __( 'Joined %s', 'jetpack-videopress-pkg' ), $date ),
245                );
246        }
247    }
248
249    /**
250     * The video a block shows: the video page's, or the newest upload.
251     *
252     * @param array $attributes Block attributes.
253     *
254     * @return WP_Post|null
255     */
256    private static function block_video( $attributes ) {
257        if ( isset( $attributes['source'] ) && 'latest' === $attributes['source'] ) {
258            return Channel_Data::latest_video();
259        }
260        return Channel::is_video_page() ? Channel::current_video() : null;
261    }
262
263    /**
264     * Video details: eyebrow, title, views and age, description and playlist chips.
265     *
266     * @param array $attributes Block attributes.
267     *
268     * @return string
269     */
270    public static function render_video_details( $attributes ) {
271        $attributes = is_array( $attributes ) ? $attributes : array();
272        $post       = self::block_video( $attributes );
273        $is_latest  = isset( $attributes['source'] ) && 'latest' === $attributes['source'];
274        $guid       = $post instanceof WP_Post ? Channel::guid( $post ) : '';
275        if ( ! $post instanceof WP_Post || '' === $guid ) {
276            return '';
277        }
278        $details  = Channel_Data::video_details( $guid, $post );
279        $record   = Channel_Data::record( $post );
280        $playlist = $is_latest ? null : Channel::queried_playlist();
281        if ( $playlist && ! in_array( $guid, array_column( $playlist['videos'], 'guid' ), true ) ) {
282            $playlist = null;
283        }
284
285        $markup = '';
286        if ( self::flag( $attributes, 'showEyebrow', true ) ) {
287            $markup .= sprintf( '<span class="videopress-video-details__eyebrow">%s</span>', esc_html( self::episode_label( $guid, $playlist ) ) );
288        }
289        if ( self::flag( $attributes, 'showTitle', true ) && '' !== $details['title'] ) {
290            $level   = self::heading_level( $attributes, $is_latest ? 2 : 1 );
291            $title   = $is_latest
292                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( Channel::video_url( $post ) ), esc_html( $details['title'] ) )
293                : esc_html( $details['title'] );
294            $markup .= sprintf( '<h%1$d class="videopress-video-details__title">%2$s</h%1$d>', $level, $title );
295        }
296        if ( self::flag( $attributes, 'showMeta', true ) && $record ) {
297            $meta = Channel_Data::views_and_age( Channel_Data::with_views( array( $record ) )[0], ! $is_latest );
298            if ( '' !== $meta ) {
299                $markup .= sprintf( '<div class="videopress-video-details__meta">%s</div>', esc_html( $meta ) );
300            }
301        }
302        if ( self::flag( $attributes, 'showDescription', true ) && '' !== trim( wp_strip_all_tags( $details['description'] ) ) ) {
303            $markup .= sprintf( '<div class="videopress-video-details__description">%s</div>', wpautop( esc_html( wp_strip_all_tags( $details['description'] ) ) ) );
304        }
305        if ( self::flag( $attributes, 'showPlaylists', true ) ) {
306            $chips = '';
307            foreach ( Channel::playlists_with_video( $guid ) as $holder ) {
308                $chips .= sprintf(
309                    '<a class="videopress-video-details__chip" href="%1$s">%2$s</a>',
310                    esc_url( Channel::playlist_url( $holder['key'] ) ),
311                    /* translators: %s: playlist title. */
312                    esc_html( sprintf( __( 'Playlist: %s', 'jetpack-videopress-pkg' ), '' !== $holder['title'] ? $holder['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' ) ) )
313                );
314            }
315            if ( '' !== $chips ) {
316                $markup .= sprintf( '<div class="videopress-video-details__chips">%s</div>', $chips );
317            }
318        }
319        if ( '' === $markup ) {
320            return '';
321        }
322
323        return sprintf( '<div %1$s>%2$s</div>', get_block_wrapper_attributes( array( 'class' => 'videopress-video-details' ) ), $markup );
324    }
325
326    /**
327     * "Episode 12" by upload order, or "Episode 3 · Playlist name" within a playlist.
328     *
329     * @param string     $guid     The video.
330     * @param array|null $playlist The playlist the video is played from.
331     *
332     * @return string
333     */
334    private static function episode_label( $guid, $playlist ) {
335        if ( $playlist ) {
336            $position = array_search( $guid, array_column( $playlist['videos'], 'guid' ), true );
337            $label    = sprintf(
338                /* translators: %s: episode number. */
339                __( 'Episode %s', 'jetpack-videopress-pkg' ),
340                number_format_i18n( (int) $position + 1 )
341            );
342            return '' !== $playlist['title'] ? $label . ' · ' . $playlist['title'] : $label;
343        }
344        $order    = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
345        $position = array_search( $guid, $order, true );
346        /* translators: %s: episode number. */
347        return sprintf( __( 'Episode %s', 'jetpack-videopress-pkg' ), number_format_i18n( false === $position ? count( $order ) + 1 : $position + 1 ) );
348    }
349
350    /**
351     * Playlist details: cover, eyebrow, title, channel, meta, description, Play all and Share.
352     *
353     * @param array $attributes Block attributes.
354     *
355     * @return string
356     */
357    public static function render_playlist_details( $attributes ) {
358        $attributes = is_array( $attributes ) ? $attributes : array();
359        $source     = isset( $attributes['source'] ) && 'index' === $attributes['source'] ? 'index' : 'queried';
360        $playlist   = Channel::source_playlist( $source, isset( $attributes['sourcePlaylistId'] ) ? (string) $attributes['sourcePlaylistId'] : '' );
361        if ( ! $playlist ) {
362            return '';
363        }
364
365        $count    = count( $playlist['videos'] );
366        $page_url = Channel::playlist_url( $playlist['key'] );
367        $first_id = $count ? Channel::attachment_id( $playlist['videos'][0]['guid'] ) : 0;
368        $title    = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
369        /* translators: %s: number of videos. */
370        $count_label = sprintf( _n( '%s video', '%s videos', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) );
371
372        $markup = '';
373        if ( self::flag( $attributes, 'showCover', true ) ) {
374            $poster  = $count ? Channel_Data::video_details( $playlist['videos'][0]['guid'], $first_id ? $first_id : null )['poster'] : '';
375            $markup .= sprintf(
376                '<div class="videopress-playlist-details__cover"><span class="videopress-playlist-details__deck" aria-hidden="true"></span><span class="videopress-playlist-details__frame">%1$s<span class="videopress-playlist-details__badge">%2$s</span></span></div>',
377                '' !== $poster ? sprintf( '<img class="videopress-playlist-details__poster" src="%s" alt="" loading="lazy" />', esc_url( $poster ) ) : '',
378                esc_html( $count_label )
379            );
380        }
381        if ( self::flag( $attributes, 'showEyebrow', true ) ) {
382            $markup .= sprintf( '<span class="videopress-playlist-details__eyebrow">%s</span>', esc_html__( 'Playlist', 'jetpack-videopress-pkg' ) );
383        }
384        $level   = self::heading_level( $attributes, 'index' === $source ? 3 : 1 );
385        $markup .= sprintf(
386            '<h%1$d class="videopress-playlist-details__title">%2$s</h%1$d>',
387            $level,
388            'index' === $source ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $page_url ), esc_html( $title ) ) : esc_html( $title )
389        );
390        if ( self::flag( $attributes, 'showChannel', true ) ) {
391            $markup .= sprintf( '<a class="videopress-playlist-details__channel" href="%1$s">%2$s</a>', esc_url( home_url( '/' ) ), esc_html( get_bloginfo( 'name' ) ) );
392        }
393        if ( self::flag( $attributes, 'showMeta', true ) ) {
394            $post    = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
395            $meta    = All_Playlists_Block::updated_meta(
396                array(
397                    'post'   => $post instanceof WP_Post ? $post : null,
398                    'videos' => $playlist['videos'],
399                )
400            );
401            $markup .= sprintf( '<div class="videopress-playlist-details__meta">%s</div>', esc_html( implode( ' · ', array_filter( array( $count_label, $meta ) ) ) ) );
402        }
403        if ( self::flag( $attributes, 'showDescription', true ) && '' !== $playlist['description'] ) {
404            $markup .= sprintf( '<p class="videopress-playlist-details__description">%s</p>', esc_html( $playlist['description'] ) );
405        }
406        if ( self::flag( $attributes, 'showActions', true ) ) {
407            $play_url = $first_id ? Channel::video_url( $first_id, $playlist['key'] ) : '';
408            $markup  .= sprintf(
409                '<div class="videopress-playlist-details__actions wp-block-buttons">%1$s<div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button videopress-playlist-details__share" href="%2$s" data-share-url="%2$s">%3$s</a></div></div>',
410                '' !== $play_url ? sprintf( '<div class="wp-block-button"><a class="wp-block-button__link wp-element-button videopress-playlist-details__play" href="%1$s">%2$s</a></div>', esc_url( $play_url ), esc_html__( 'Play all', 'jetpack-videopress-pkg' ) ) : '',
411                esc_url( $page_url ),
412                esc_html__( 'Share', 'jetpack-videopress-pkg' )
413            );
414        }
415
416        return sprintf(
417            '<div %1$s>%2$s</div>',
418            get_block_wrapper_attributes( array( 'class' => 'videopress-playlist-details is-source-' . $source ) ),
419            $markup
420        );
421    }
422
423    /**
424     * Previous and next episode on a video page: within the playlist the page
425     * plays from, else in upload order.
426     *
427     * @return string
428     */
429    public static function render_episode_navigation() {
430        $guid = Channel::queried_guid();
431        if ( '' === $guid ) {
432            return '';
433        }
434
435        $playlist = Channel::queried_playlist();
436        $sequence = $playlist ? array_column( $playlist['videos'], 'guid' ) : array();
437        if ( ! in_array( $guid, $sequence, true ) ) {
438            $playlist = null;
439            $sequence = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
440        }
441        $position = array_search( $guid, $sequence, true );
442        if ( false === $position ) {
443            return '';
444        }
445
446        $links = '';
447        foreach ( array(
448            'prev' => array( $position - 1, __( '← Previous episode', 'jetpack-videopress-pkg' ) ),
449            'next' => array( $position + 1, __( 'Next episode →', 'jetpack-videopress-pkg' ) ),
450        ) as $direction => list( $index, $label ) ) {
451            $other = $sequence[ $index ] ?? '';
452            $id    = '' !== $other ? Channel::attachment_id( $other ) : 0;
453            if ( ! $id ) {
454                continue;
455            }
456            $links .= sprintf(
457                '<a class="videopress-episode-navigation__link is-%1$s" href="%2$s" rel="%1$s"><span class="videopress-episode-navigation__label">%3$s</span><span class="videopress-episode-navigation__title">%4$s</span></a>',
458                esc_attr( $direction ),
459                esc_url( Channel::video_url( $id, $playlist ? $playlist['key'] : '' ) ),
460                esc_html( $label ),
461                esc_html( Channel_Data::video_details( $other, $id )['title'] )
462            );
463        }
464        if ( '' === $links ) {
465            return '';
466        }
467
468        return sprintf(
469            '<nav %1$s aria-label="%2$s">%3$s</nav>',
470            get_block_wrapper_attributes( array( 'class' => 'videopress-episode-navigation' ) ),
471            esc_attr__( 'Episodes', 'jetpack-videopress-pkg' ),
472            $links
473        );
474    }
475}