Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.32% covered (success)
98.32%
293 / 298
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Blocks
98.32% covered (success)
98.32%
293 / 298
76.92% covered (warning)
76.92%
10 / 13
129
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
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
25.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%
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 ( '' === $markup ) {
399            return '';
400        }
401
402        return sprintf( '<div %1$s>%2$s</div>', get_block_wrapper_attributes( array( 'class' => 'videopress-video-details' ) ), $markup );
403    }
404
405    /**
406     * "Episode 12" by upload order, or "Episode 3 · Playlist name" within a playlist.
407     *
408     * @param string     $guid     The video.
409     * @param array|null $playlist The playlist the video is played from.
410     *
411     * @return string
412     */
413    private static function episode_label( $guid, $playlist ) {
414        if ( $playlist ) {
415            $position = array_search( $guid, array_column( $playlist['videos'], 'guid' ), true );
416            $label    = sprintf(
417                /* translators: %s: episode number. */
418                __( 'Episode %s', 'jetpack-videopress-pkg' ),
419                number_format_i18n( (int) $position + 1 )
420            );
421            return '' !== $playlist['title'] ? $label . ' · ' . $playlist['title'] : $label;
422        }
423        $order    = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
424        $position = array_search( $guid, $order, true );
425        /* translators: %s: episode number. */
426        return sprintf( __( 'Episode %s', 'jetpack-videopress-pkg' ), number_format_i18n( false === $position ? count( $order ) + 1 : $position + 1 ) );
427    }
428
429    /**
430     * Playlist details: cover, eyebrow, title, channel, meta, description, Play all and Share.
431     *
432     * @param array $attributes Block attributes.
433     *
434     * @return string
435     */
436    public static function render_playlist_details( $attributes ) {
437        $attributes = is_array( $attributes ) ? $attributes : array();
438        $source     = isset( $attributes['source'] ) && 'index' === $attributes['source'] ? 'index' : 'queried';
439        $playlist   = Channel::source_playlist( $source, isset( $attributes['sourcePlaylistId'] ) ? (string) $attributes['sourcePlaylistId'] : '' );
440        if ( ! $playlist ) {
441            return 'index' === $source && ! Channel::playlists()
442                ? self::render_empty_state( 'playlists', $attributes, 'videopress-playlist-details is-source-index' )
443                : '';
444        }
445
446        $count    = count( $playlist['videos'] );
447        $page_url = Channel::playlist_url( $playlist['key'] );
448        $first_id = $count ? Channel::attachment_id( $playlist['videos'][0]['guid'] ) : 0;
449        $title    = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
450        /* translators: %s: number of videos. */
451        $count_label = sprintf( _n( '%s video', '%s videos', $count, 'jetpack-videopress-pkg' ), number_format_i18n( $count ) );
452
453        $markup = '';
454        if ( self::flag( $attributes, 'showCover', true ) ) {
455            $poster  = $count ? Channel_Data::video_details( $playlist['videos'][0]['guid'], $first_id ? $first_id : null )['poster'] : '';
456            $markup .= sprintf(
457                '<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>',
458                '' !== $poster ? sprintf( '<img class="videopress-playlist-details__poster" src="%s" alt="" loading="lazy" />', esc_url( $poster ) ) : '',
459                esc_html( $count_label )
460            );
461        }
462        if ( self::flag( $attributes, 'showEyebrow', true ) ) {
463            $markup .= sprintf( '<span class="videopress-playlist-details__eyebrow">%s</span>', esc_html__( 'Playlist', 'jetpack-videopress-pkg' ) );
464        }
465        $level   = self::heading_level( $attributes, 'index' === $source ? 3 : 1 );
466        $markup .= sprintf(
467            '<h%1$d class="videopress-playlist-details__title">%2$s</h%1$d>',
468            $level,
469            'index' === $source ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $page_url ), esc_html( $title ) ) : esc_html( $title )
470        );
471        if ( self::flag( $attributes, 'showChannel', true ) ) {
472            $markup .= sprintf( '<a class="videopress-playlist-details__channel" href="%1$s">%2$s</a>', esc_url( home_url( '/' ) ), esc_html( get_bloginfo( 'name' ) ) );
473        }
474        if ( self::flag( $attributes, 'showMeta', true ) ) {
475            $post    = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
476            $meta    = All_Playlists_Block::updated_meta(
477                array(
478                    'post'   => $post instanceof WP_Post ? $post : null,
479                    'videos' => $playlist['videos'],
480                )
481            );
482            $markup .= sprintf( '<div class="videopress-playlist-details__meta">%s</div>', esc_html( implode( ' · ', array_filter( array( $count_label, $meta ) ) ) ) );
483        }
484        if ( self::flag( $attributes, 'showDescription', true ) && '' !== $playlist['description'] ) {
485            $markup .= sprintf( '<p class="videopress-playlist-details__description">%s</p>', esc_html( $playlist['description'] ) );
486        }
487        if ( self::flag( $attributes, 'showActions', true ) ) {
488            $play_url = $first_id ? Channel::video_url( $first_id, $playlist['key'] ) : '';
489            $markup  .= sprintf(
490                '<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>',
491                '' !== $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' ) ) : '',
492                esc_url( $page_url ),
493                esc_html__( 'Share', 'jetpack-videopress-pkg' )
494            );
495        }
496
497        return sprintf(
498            '<div %1$s>%2$s</div>',
499            get_block_wrapper_attributes( array( 'class' => 'videopress-playlist-details is-source-' . $source ) ),
500            $markup
501        );
502    }
503
504    /**
505     * Previous and next episode on a video page: within the playlist the page
506     * plays from, else in upload order.
507     *
508     * @return string
509     */
510    public static function render_episode_navigation() {
511        $guid = Channel::queried_guid();
512        if ( '' === $guid ) {
513            return '';
514        }
515
516        $playlist = Channel::queried_playlist();
517        $sequence = $playlist ? array_column( $playlist['videos'], 'guid' ) : array();
518        if ( ! in_array( $guid, $sequence, true ) ) {
519            $playlist = null;
520            $sequence = array_reverse( array_column( Channel_Data::videos(), 'guid' ) );
521        }
522        $position = array_search( $guid, $sequence, true );
523        if ( false === $position ) {
524            return '';
525        }
526
527        $links = '';
528        foreach ( array(
529            'prev' => array( $position - 1, __( '← Previous episode', 'jetpack-videopress-pkg' ) ),
530            'next' => array( $position + 1, __( 'Next episode →', 'jetpack-videopress-pkg' ) ),
531        ) as $direction => list( $index, $label ) ) {
532            $other = $sequence[ $index ] ?? '';
533            $id    = '' !== $other ? Channel::attachment_id( $other ) : 0;
534            if ( ! $id ) {
535                continue;
536            }
537            $links .= sprintf(
538                '<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>',
539                esc_attr( $direction ),
540                esc_url( Channel::video_url( $id, $playlist ? $playlist['key'] : '' ) ),
541                esc_html( $label ),
542                esc_html( Channel_Data::video_details( $other, $id )['title'] )
543            );
544        }
545        if ( '' === $links ) {
546            return '';
547        }
548
549        return sprintf(
550            '<nav %1$s aria-label="%2$s">%3$s</nav>',
551            get_block_wrapper_attributes( array( 'class' => 'videopress-episode-navigation' ) ),
552            esc_attr__( 'Episodes', 'jetpack-videopress-pkg' ),
553            $links
554        );
555    }
556}