Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
294 / 315
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Blocks
93.33% covered (success)
93.33%
294 / 315
71.43% covered (warning)
71.43%
10 / 14
140.40
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
 empty_state_mode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 render_empty_state
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
12
 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
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
26.26
 render_classification_chips
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 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%
54 / 54
100.00% covered (success)
100.00%
1 / 1
26
 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     * Empty state modes, the first being the default ( render nothing ).
113     *
114     * @var string[]
115     */
116    const EMPTY_STATE_MODES = array( 'none', 'author', 'all' );
117
118    /**
119     * A block's `emptyState` mode.
120     *
121     * @param mixed $attributes Block attributes.
122     *
123     * @return string One of EMPTY_STATE_MODES.
124     */
125    public static function empty_state_mode( $attributes ) {
126        return is_array( $attributes ) && isset( $attributes['emptyState'] ) && in_array( $attributes['emptyState'], self::EMPTY_STATE_MODES, true )
127            ? $attributes['emptyState']
128            : self::EMPTY_STATE_MODES[0];
129    }
130
131    /**
132     * The empty state a block shows when the channel has no videos or no playlists:
133     * a call to action for people who can add some, a short note for visitors in "all" mode.
134     *
135     * @param string $kind          "videos" or "playlists".
136     * @param mixed  $attributes    Block attributes, for the `emptyState` mode.
137     * @param string $wrapper_class Classes for the block wrapper, besides `is-empty`.
138     *
139     * @return string Markup, or an empty string when nothing should show.
140     */
141    public static function render_empty_state( $kind, $attributes, $wrapper_class = '' ) {
142        $mode = self::empty_state_mode( $attributes );
143        if ( 'none' === $mode ) {
144            return '';
145        }
146        $is_videos = 'playlists' !== $kind;
147        $can_act   = current_user_can( $is_videos ? 'upload_files' : 'edit_posts' );
148        if ( ! $can_act && 'all' !== $mode ) {
149            return '';
150        }
151
152        $action = '';
153        if ( $can_act ) {
154            $copy   = $is_videos
155                ? array(
156                    __( 'Upload your first video', 'jetpack-videopress-pkg' ),
157                    __( 'Videos you upload to the media library appear here, with their own page on your channel.', 'jetpack-videopress-pkg' ),
158                )
159                : array(
160                    __( 'Create your first playlist', 'jetpack-videopress-pkg' ),
161                    __( 'Add a Video Playlist block to a post or page and give it a title: it is listed here and gets its own page.', 'jetpack-videopress-pkg' ),
162                );
163            $action = sprintf(
164                '<a class="videopress-empty-state__action wp-element-button" href="%1$s">%2$s</a>',
165                esc_url( admin_url( $is_videos ? 'media-new.php' : 'post-new.php' ) ),
166                esc_html( $is_videos ? __( 'Upload a video', 'jetpack-videopress-pkg' ) : __( 'Write a post', 'jetpack-videopress-pkg' ) )
167            );
168        } else {
169            $copy = $is_videos
170                ? array( __( 'No videos yet', 'jetpack-videopress-pkg' ), __( 'New videos will appear here.', 'jetpack-videopress-pkg' ) )
171                : array( __( 'No playlists yet', 'jetpack-videopress-pkg' ), __( 'Playlists will appear here.', 'jetpack-videopress-pkg' ) );
172        }
173
174        $markup = sprintf(
175            '<div class="videopress-empty-state is-%1$s is-for-%2$s"><p class="videopress-empty-state__title">%3$s</p><p class="videopress-empty-state__text">%4$s</p>%5$s</div>',
176            $is_videos ? 'videos' : 'playlists',
177            $can_act ? 'author' : 'visitor',
178            esc_html( $copy[0] ),
179            esc_html( $copy[1] ),
180            $action
181        );
182
183        return sprintf(
184            '<div %1$s>%2$s</div>',
185            get_block_wrapper_attributes( array( 'class' => trim( $wrapper_class . ' is-empty' ) ) ),
186            $markup
187        );
188    }
189
190    /**
191     * A boolean attribute.
192     *
193     * @param array  $attributes    Block attributes.
194     * @param string $key           Attribute.
195     * @param bool   $default_value Value when unset.
196     *
197     * @return bool
198     */
199    private static function flag( $attributes, $key, $default_value ) {
200        return isset( $attributes[ $key ] ) ? (bool) $attributes[ $key ] : $default_value;
201    }
202
203    /**
204     * A heading level attribute, 1 to 6; anything else ( 0 = automatic ) picks the default.
205     *
206     * @param array $attributes    Block attributes.
207     * @param int   $default_value Level when unset.
208     *
209     * @return int
210     */
211    private static function heading_level( $attributes, $default_value ) {
212        $level = isset( $attributes['headingLevel'] ) && is_numeric( $attributes['headingLevel'] ) ? (int) $attributes['headingLevel'] : 0;
213        return $level >= 1 && $level <= 6 ? $level : $default_value;
214    }
215
216    /**
217     * Channel details: the handle, subscribers, videos, playlists, views and join date.
218     *
219     * @param array $attributes Block attributes.
220     *
221     * @return string
222     */
223    public static function render_channel_details( $attributes ) {
224        $attributes = is_array( $attributes ) ? $attributes : array();
225        $items      = isset( $attributes['items'] ) && is_array( $attributes['items'] )
226            ? array_values( array_intersect( array_unique( $attributes['items'] ), self::CHANNEL_ITEMS ) )
227            : self::DEFAULT_CHANNEL_ITEMS;
228        $layout     = isset( $attributes['layout'] ) && 'list' === $attributes['layout'] ? 'list' : 'inline';
229
230        $markup = '';
231        foreach ( $items as $item ) {
232            $value = self::channel_item( $item );
233            if ( null === $value ) {
234                continue;
235            }
236            $markup .= 'list' === $layout
237                ? sprintf(
238                    '<div class="videopress-channel-details__item is-%1$s"><dt>%2$s</dt><dd>%3$s</dd></div>',
239                    esc_attr( $item ),
240                    esc_html( $value['label'] ),
241                    esc_html( $value['value'] )
242                )
243                : sprintf(
244                    '<span class="videopress-channel-details__item is-%1$s">%2$s</span>',
245                    esc_attr( $item ),
246                    esc_html( $value['inline'] )
247                );
248        }
249        if ( '' === $markup ) {
250            return '';
251        }
252
253        $tag = 'list' === $layout ? 'dl' : 'div';
254        return sprintf(
255            '<%1$s %2$s>%3$s</%1$s>',
256            $tag,
257            get_block_wrapper_attributes( array( 'class' => 'videopress-channel-details is-layout-' . $layout ) ),
258            $markup
259        );
260    }
261
262    /**
263     * One channel details item, or null when its value is unknown.
264     *
265     * @param string $item One of CHANNEL_ITEMS.
266     *
267     * @return array{label: string, value: string, inline: string}|null
268     */
269    private static function channel_item( $item ) {
270        switch ( $item ) {
271            case 'handle':
272                $handle = Channel_Data::handle();
273                return '' === $handle ? null : array(
274                    'label'  => __( 'Handle', 'jetpack-videopress-pkg' ),
275                    'value'  => $handle,
276                    'inline' => $handle,
277                );
278            case 'subscribers':
279                $count = Channel_Data::subscriber_count();
280                if ( null === $count ) {
281                    return null;
282                }
283                return array(
284                    'label'  => __( 'Subscribers', 'jetpack-videopress-pkg' ),
285                    'value'  => Channel_Data::compact_number( $count ),
286                    /* translators: %s: number of subscribers, e.g. "48K". */
287                    'inline' => sprintf( _n( '%s subscriber', '%s subscribers', $count, 'jetpack-videopress-pkg' ), Channel_Data::compact_number( $count ) ),
288                );
289            case 'videos':
290                $count = Channel_Data::video_count();
291                return array(
292                    'label'  => __( 'Videos', 'jetpack-videopress-pkg' ),
293                    'value'  => number_format_i18n( $count ),
294                    /* translators: %s: number of videos. */
295                    'inline' => sprintf( _n( '%s video', '%s videos', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) ),
296                );
297            case 'playlists':
298                $count = count( Channel::playlists() );
299                return array(
300                    'label'  => __( 'Playlists', 'jetpack-videopress-pkg' ),
301                    'value'  => number_format_i18n( $count ),
302                    /* translators: %s: number of playlists. */
303                    'inline' => sprintf( _n( '%s playlist', '%s playlists', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) ),
304                );
305            case 'views':
306                $count = Channel_Data::total_views();
307                return array(
308                    'label'  => __( 'Total views', 'jetpack-videopress-pkg' ),
309                    'value'  => Channel_Data::compact_number( $count ),
310                    /* translators: %s: number of views, e.g. "1.2M". */
311                    'inline' => sprintf( _n( '%s view', '%s views', $count, 'jetpack-videopress-pkg' ), Channel_Data::compact_number( $count ) ),
312                );
313            default: // 'joined', the last of CHANNEL_ITEMS.
314                $time = Channel_Data::joined();
315                if ( ! $time ) {
316                    return null;
317                }
318                $date = date_i18n( get_option( 'date_format' ), $time );
319                return array(
320                    'label'  => __( 'Joined', 'jetpack-videopress-pkg' ),
321                    'value'  => $date,
322                    /* translators: %s: date the channel started. */
323                    'inline' => sprintf( __( 'Joined %s', 'jetpack-videopress-pkg' ), $date ),
324                );
325        }
326    }
327
328    /**
329     * The video a block shows: the video page's, or the newest upload.
330     *
331     * @param array $attributes Block attributes.
332     *
333     * @return WP_Post|null
334     */
335    private static function block_video( $attributes ) {
336        if ( isset( $attributes['source'] ) && 'latest' === $attributes['source'] ) {
337            return Channel_Data::latest_video();
338        }
339        return Channel::is_video_page() ? Channel::current_video() : null;
340    }
341
342    /**
343     * Video details: eyebrow, title, views and age, description and playlist chips.
344     *
345     * @param array $attributes Block attributes.
346     *
347     * @return string
348     */
349    public static function render_video_details( $attributes ) {
350        $attributes = is_array( $attributes ) ? $attributes : array();
351        $post       = self::block_video( $attributes );
352        $is_latest  = isset( $attributes['source'] ) && 'latest' === $attributes['source'];
353        $guid       = $post instanceof WP_Post ? Channel::guid( $post ) : '';
354        if ( ! $post instanceof WP_Post || '' === $guid ) {
355            return $is_latest ? self::render_empty_state( 'videos', $attributes, 'videopress-video-details' ) : '';
356        }
357        $details  = Channel_Data::video_details( $guid, $post );
358        $record   = Channel_Data::record( $post );
359        $playlist = $is_latest ? null : Channel::queried_playlist();
360        if ( $playlist && ! in_array( $guid, array_column( $playlist['videos'], 'guid' ), true ) ) {
361            $playlist = null;
362        }
363
364        $markup = '';
365        if ( self::flag( $attributes, 'showEyebrow', true ) ) {
366            $markup .= sprintf( '<span class="videopress-video-details__eyebrow">%s</span>', esc_html( self::episode_label( $guid, $playlist ) ) );
367        }
368        if ( self::flag( $attributes, 'showTitle', true ) && '' !== $details['title'] ) {
369            $level   = self::heading_level( $attributes, $is_latest ? 2 : 1 );
370            $title   = $is_latest
371                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( Channel::video_url( $post ) ), esc_html( $details['title'] ) )
372                : esc_html( $details['title'] );
373            $markup .= sprintf( '<h%1$d class="videopress-video-details__title">%2$s</h%1$d>', $level, $title );
374        }
375        if ( self::flag( $attributes, 'showMeta', true ) && $record ) {
376            $meta = Channel_Data::views_and_age( Channel_Data::with_views( array( $record ) )[0], ! $is_latest );
377            if ( '' !== $meta ) {
378                $markup .= sprintf( '<div class="videopress-video-details__meta">%s</div>', esc_html( $meta ) );
379            }
380        }
381        if ( self::flag( $attributes, 'showDescription', true ) && '' !== trim( wp_strip_all_tags( $details['description'] ) ) ) {
382            $markup .= sprintf( '<div class="videopress-video-details__description">%s</div>', wpautop( esc_html( wp_strip_all_tags( $details['description'] ) ) ) );
383        }
384        if ( self::flag( $attributes, 'showPlaylists', true ) ) {
385            $chips = '';
386            foreach ( Channel::playlists_with_video( $guid ) as $holder ) {
387                $chips .= sprintf(
388                    '<a class="videopress-video-details__chip" href="%1$s">%2$s</a>',
389                    esc_url( Channel::playlist_url( $holder['key'] ) ),
390                    /* translators: %s: playlist title. */
391                    esc_html( sprintf( __( 'Playlist: %s', 'jetpack-videopress-pkg' ), '' !== $holder['title'] ? $holder['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' ) ) )
392                );
393            }
394            if ( '' !== $chips ) {
395                $markup .= sprintf( '<div class="videopress-video-details__chips">%s</div>', $chips );
396            }
397        }
398        if ( self::flag( $attributes, 'showClassification', false ) ) {
399            $markup .= self::render_classification_chips( $post->ID );
400        }
401        if ( '' === $markup ) {
402            return '';
403        }
404
405        return sprintf( '<div %1$s>%2$s</div>', get_block_wrapper_attributes( array( 'class' => 'videopress-video-details' ) ), $markup );
406    }
407
408    /**
409     * A video's series, type, topics and people as chips linking to the filtered Videos page.
410     *
411     * @param int $attachment_id The video's attachment.
412     *
413     * @return string Markup; empty when the video is not classified.
414     */
415    private static function render_classification_chips( $attachment_id ) {
416        $taxonomies = Classification::taxonomies();
417        $chips      = '';
418        foreach ( Classification::assignments( $attachment_id ) as $taxonomy => $terms ) {
419            foreach ( $terms as $term ) {
420                $object = get_term( $term['id'], $taxonomy );
421                if ( ! $object instanceof \WP_Term ) {
422                    continue;
423                }
424                $chips .= sprintf(
425                    '<a class="videopress-video-details__chip videopress-video-details__chip--%1$s" href="%2$s"><span class="videopress-video-details__chip-label">%3$s:</span> %4$s</a>',
426                    esc_attr( str_replace( 'videopress_', '', $taxonomy ) ),
427                    esc_url( Channel_Data::videos_page_url( $object ) ),
428                    esc_html( $taxonomies[ $taxonomy ]['singular'] ),
429                    esc_html( $term['name'] )
430                );
431            }
432        }
433        return '' === $chips ? '' : sprintf( '<div class="videopress-video-details__chips videopress-video-details__classification">%s</div>', $chips );
434    }
435
436    /**
437     * "Episode 12" by upload order, or "Episode 3 · Playlist name" within a playlist.
438     *
439     * @param string     $guid     The video.
440     * @param array|null $playlist The playlist the video is played from.
441     *
442     * @return string
443     */
444    private static function episode_label( $guid, $playlist ) {
445        if ( $playlist ) {
446            $position = array_search( $guid, array_column( $playlist['videos'], 'guid' ), true );
447            $label    = sprintf(
448                /* translators: %s: episode number. */
449                __( 'Episode %s', 'jetpack-videopress-pkg' ),
450                number_format_i18n( (int) $position + 1 )
451            );
452            return '' !== $playlist['title'] ? $label . ' · ' . $playlist['title'] : $label;
453        }
454        $order    = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
455        $position = array_search( $guid, $order, true );
456        /* translators: %s: episode number. */
457        return sprintf( __( 'Episode %s', 'jetpack-videopress-pkg' ), number_format_i18n( false === $position ? count( $order ) + 1 : $position + 1 ) );
458    }
459
460    /**
461     * Playlist details: cover, eyebrow, title, channel, meta, description, Play all and Share.
462     *
463     * @param array $attributes Block attributes.
464     *
465     * @return string
466     */
467    public static function render_playlist_details( $attributes ) {
468        $attributes = is_array( $attributes ) ? $attributes : array();
469        $source     = isset( $attributes['source'] ) && 'index' === $attributes['source'] ? 'index' : 'queried';
470        $playlist   = Channel::source_playlist( $source, isset( $attributes['sourcePlaylistId'] ) ? (string) $attributes['sourcePlaylistId'] : '' );
471        if ( ! $playlist ) {
472            return 'index' === $source && ! Channel::playlists()
473                ? self::render_empty_state( 'playlists', $attributes, 'videopress-playlist-details is-source-index' )
474                : '';
475        }
476
477        $count    = count( $playlist['videos'] );
478        $page_url = Channel::playlist_url( $playlist['key'] );
479        $first_id = $count ? Channel::attachment_id( $playlist['videos'][0]['guid'] ) : 0;
480        $title    = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
481        /* translators: %s: number of videos. */
482        $count_label = sprintf( _n( '%s video', '%s videos', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) );
483
484        $markup = '';
485        if ( self::flag( $attributes, 'showCover', true ) ) {
486            $poster  = $count ? Channel_Data::video_details( $playlist['videos'][0]['guid'], $first_id ? $first_id : null )['poster'] : '';
487            $markup .= sprintf(
488                '<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>',
489                '' !== $poster ? sprintf( '<img class="videopress-playlist-details__poster" src="%s" alt="" loading="lazy" />', esc_url( $poster ) ) : '',
490                esc_html( $count_label )
491            );
492        }
493        if ( self::flag( $attributes, 'showEyebrow', true ) ) {
494            $markup .= sprintf( '<span class="videopress-playlist-details__eyebrow">%s</span>', esc_html__( 'Playlist', 'jetpack-videopress-pkg' ) );
495        }
496        $level   = self::heading_level( $attributes, 'index' === $source ? 3 : 1 );
497        $markup .= sprintf(
498            '<h%1$d class="videopress-playlist-details__title">%2$s</h%1$d>',
499            $level,
500            'index' === $source ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $page_url ), esc_html( $title ) ) : esc_html( $title )
501        );
502        if ( self::flag( $attributes, 'showChannel', true ) ) {
503            $markup .= sprintf( '<a class="videopress-playlist-details__channel" href="%1$s">%2$s</a>', esc_url( home_url( '/' ) ), esc_html( get_bloginfo( 'name' ) ) );
504        }
505        if ( self::flag( $attributes, 'showMeta', true ) ) {
506            $post    = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
507            $meta    = All_Playlists_Block::updated_meta(
508                array(
509                    'post'   => $post instanceof WP_Post ? $post : null,
510                    'videos' => $playlist['videos'],
511                )
512            );
513            $markup .= sprintf( '<div class="videopress-playlist-details__meta">%s</div>', esc_html( implode( ' · ', array_filter( array( $count_label, $meta ) ) ) ) );
514        }
515        if ( self::flag( $attributes, 'showDescription', true ) && '' !== $playlist['description'] ) {
516            $markup .= sprintf( '<p class="videopress-playlist-details__description">%s</p>', esc_html( $playlist['description'] ) );
517        }
518        if ( self::flag( $attributes, 'showActions', true ) ) {
519            $play_url = $first_id ? Channel::video_url( $first_id, $playlist['key'] ) : '';
520            $markup  .= sprintf(
521                '<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>',
522                '' !== $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' ) ) : '',
523                esc_url( $page_url ),
524                esc_html__( 'Share', 'jetpack-videopress-pkg' )
525            );
526        }
527
528        return sprintf(
529            '<div %1$s>%2$s</div>',
530            get_block_wrapper_attributes( array( 'class' => 'videopress-playlist-details is-source-' . $source ) ),
531            $markup
532        );
533    }
534
535    /**
536     * Previous and next episode on a video page: within the playlist the page
537     * plays from, else in upload order.
538     *
539     * @return string
540     */
541    public static function render_episode_navigation() {
542        $guid = Channel::queried_guid();
543        if ( '' === $guid ) {
544            return '';
545        }
546
547        $playlist = Channel::queried_playlist();
548        $sequence = $playlist ? array_column( $playlist['videos'], 'guid' ) : array();
549        if ( ! in_array( $guid, $sequence, true ) ) {
550            $playlist = null;
551            $sequence = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
552        }
553        $position = array_search( $guid, $sequence, true );
554        if ( false === $position ) {
555            return '';
556        }
557
558        $links = '';
559        foreach ( array(
560            'prev' => array( $position - 1, __( '← Previous episode', 'jetpack-videopress-pkg' ) ),
561            'next' => array( $position + 1, __( 'Next episode →', 'jetpack-videopress-pkg' ) ),
562        ) as $direction => list( $index, $label ) ) {
563            $other = $sequence[ $index ] ?? '';
564            $id    = '' !== $other ? Channel::attachment_id( $other ) : 0;
565            if ( ! $id ) {
566                continue;
567            }
568            $links .= sprintf(
569                '<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>',
570                esc_attr( $direction ),
571                esc_url( Channel::video_url( $id, $playlist ? $playlist['key'] : '' ) ),
572                esc_html( $label ),
573                esc_html( Channel_Data::video_details( $other, $id )['title'] )
574            );
575        }
576        if ( '' === $links ) {
577            return '';
578        }
579
580        return sprintf(
581            '<nav %1$s aria-label="%2$s">%3$s</nav>',
582            get_block_wrapper_attributes( array( 'class' => 'videopress-episode-navigation' ) ),
583            esc_attr__( 'Episodes', 'jetpack-videopress-pkg' ),
584            $links
585        );
586    }
587}