Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.09% covered (success)
99.09%
328 / 331
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
All_Playlists_Block
99.09% covered (success)
99.09%
328 / 331
91.67% covered (success)
91.67%
11 / 12
102
0.00% covered (danger)
0.00%
0 / 1
 register
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
6.17
 settings
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 ordered_playlists
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
24
 block_style_vars
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
12
 sanitize_style_value
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 requested_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
10
 render_heading
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 render_item
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
18
 updated_meta
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 render_numbered_pagination
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
6
 render_load_more
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The All Playlists block: every Video Playlist on the site, as a gallery or a list.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Post;
11
12/**
13 * Registers and renders the `videopress/all-playlists` block from the playlist
14 * index: a paginated gallery or list of playlist cards, each linking to the
15 * post its playlist lives in.
16 */
17class All_Playlists_Block {
18
19    /**
20     * Block name.
21     *
22     * @var string
23     */
24    const BLOCK_NAME = 'videopress/all-playlists';
25
26    /**
27     * Supported layouts, the first being the default.
28     *
29     * @var string[]
30     */
31    const LAYOUTS = array( 'gallery', 'list' );
32
33    /**
34     * Supported orders, the first being the default.
35     *
36     * @var string[]
37     */
38    const ORDERS = array( 'newest', 'oldest', 'title', 'updated' );
39
40    /**
41     * Supported pagination styles, the first being the default.
42     *
43     * @var string[]
44     */
45    const PAGINATIONS = array( 'numbered', 'load-more' );
46
47    /**
48     * Bounds of the layout settings; the editor controls use the same range.
49     */
50    const MIN_COLUMNS      = 1;
51    const MAX_COLUMNS      = 6;
52    const DEFAULT_COLUMNS  = 3;
53    const MIN_PER_PAGE     = 1;
54    const MAX_PER_PAGE     = 48;
55    const DEFAULT_PER_PAGE = 6;
56
57    /**
58     * Query argument carrying the current page of a numbered pagination.
59     *
60     * @var string
61     */
62    const PAGE_QUERY_ARG = 'playlists-page';
63
64    /**
65     * Register the block.
66     *
67     * It lists Video Playlist blocks, so it is only registered where that block is.
68     *
69     * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
70     *                                   package build output; tests can point it at a fixture.
71     *
72     * @return void
73     */
74    public static function register( $metadata_file = null ) {
75        if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( Playlist_Index::BLOCK_NAME ) ) {
76            return;
77        }
78
79        if ( null === $metadata_file ) {
80            $metadata_file = __DIR__ . '/../build/block-editor/blocks/all-playlists/block.json';
81        }
82
83        if ( ! file_exists( $metadata_file ) ) {
84            return;
85        }
86
87        $metadata = json_decode(
88            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
89            file_get_contents( $metadata_file )
90        );
91
92        if ( empty( $metadata->name )
93            || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
94        ) {
95            return;
96        }
97
98        register_block_type(
99            $metadata_file,
100            array(
101                'render_callback' => array( __CLASS__, 'render' ),
102            )
103        );
104    }
105
106    /**
107     * Normalize the block attributes into the values the render uses.
108     *
109     * @param mixed $block_attributes Block attributes; anything but an array counts as none.
110     *
111     * @return array{layout: string, columns: int, per_page: int, order_by: string, show_description: bool, show_video_count: bool, pagination: string, show_updated: bool}
112     */
113    public static function settings( $block_attributes ) {
114        $block_attributes = is_array( $block_attributes ) ? $block_attributes : array();
115
116        $pick  = function ( $key, $allowed ) use ( $block_attributes ) {
117            return isset( $block_attributes[ $key ] ) && in_array( $block_attributes[ $key ], $allowed, true )
118                ? $block_attributes[ $key ]
119                : $allowed[0];
120        };
121        $clamp = function ( $key, $min, $max, $default_value ) use ( $block_attributes ) {
122            return isset( $block_attributes[ $key ] ) && is_numeric( $block_attributes[ $key ] )
123                ? max( $min, min( $max, (int) $block_attributes[ $key ] ) )
124                : $default_value;
125        };
126        $flag  = function ( $key, $default_value ) use ( $block_attributes ) {
127            return isset( $block_attributes[ $key ] ) ? (bool) $block_attributes[ $key ] : $default_value;
128        };
129
130        return array(
131            'layout'           => $pick( 'layout', self::LAYOUTS ),
132            'columns'          => $clamp( 'columns', self::MIN_COLUMNS, self::MAX_COLUMNS, self::DEFAULT_COLUMNS ),
133            'per_page'         => $clamp( 'perPage', self::MIN_PER_PAGE, self::MAX_PER_PAGE, self::DEFAULT_PER_PAGE ),
134            'order_by'         => $pick( 'orderBy', self::ORDERS ),
135            'show_description' => $flag( 'showDescription', true ),
136            'show_video_count' => $flag( 'showVideoCount', true ),
137            'pagination'       => $pick( 'pagination', self::PAGINATIONS ),
138            'show_updated'     => $flag( 'showUpdated', false ),
139        );
140    }
141
142    /**
143     * The indexed playlists in display order, each with its source post resolved.
144     *
145     * @param string $order_by One of ORDERS.
146     *
147     * @return array[] Records with `key`, `title`, `description`, `videos` and `post` (WP_Post|null).
148     */
149    public static function ordered_playlists( $order_by ) {
150        $playlists = array();
151        $post_ids  = array();
152        foreach ( Playlist_Index::get_playlists() as $key => $record ) {
153            if ( ! is_array( $record ) ) {
154                continue;
155            }
156            $post_id     = isset( $record['post_id'] ) ? absint( $record['post_id'] ) : 0;
157            $playlists[] = array(
158                'key'         => (string) $key,
159                'title'       => isset( $record['title'] ) && is_string( $record['title'] ) ? $record['title'] : '',
160                'description' => isset( $record['description'] ) && is_string( $record['description'] ) ? $record['description'] : '',
161                'videos'      => isset( $record['videos'] ) && is_array( $record['videos'] ) ? $record['videos'] : array(),
162                'post_id'     => $post_id,
163                'post'        => null,
164            );
165            if ( $post_id ) {
166                $post_ids[] = $post_id;
167            }
168        }
169
170        if ( $post_ids ) {
171            _prime_post_caches( array_unique( $post_ids ), false, false );
172            foreach ( $playlists as &$playlist ) {
173                $post = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
174                if ( $post instanceof WP_Post && 'publish' === $post->post_status ) {
175                    $playlist['post'] = $post;
176                }
177            }
178            unset( $playlist );
179        }
180
181        if ( 'title' === $order_by ) {
182            usort(
183                $playlists,
184                function ( $a, $b ) {
185                    $result = strnatcasecmp( $a['title'], $b['title'] );
186                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
187                }
188            );
189        } else {
190            // By the source post's publish ( or, for "updated", modified ) date; playlists without a post go last.
191            $date_of = function ( $playlist ) use ( $order_by ) {
192                if ( ! $playlist['post'] ) {
193                    return '';
194                }
195                return 'updated' === $order_by ? $playlist['post']->post_modified_gmt : $playlist['post']->post_date_gmt;
196            };
197            usort(
198                $playlists,
199                function ( $a, $b ) use ( $date_of, $order_by ) {
200                    $date_a = $date_of( $a );
201                    $date_b = $date_of( $b );
202                    if ( '' === $date_a || '' === $date_b ) {
203                        $result = strcmp( $date_b, $date_a );
204                    } else {
205                        $result = 'oldest' === $order_by ? strcmp( $date_a, $date_b ) : strcmp( $date_b, $date_a );
206                    }
207                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
208                }
209            );
210        }
211
212        return $playlists;
213    }
214
215    /**
216     * The typography and color settings the Styles tab stores on the block, as
217     * CSS custom properties plus a marker class per property.
218     *
219     * Block supports put these on the wrapper, but headings inside it keep the
220     * theme's heading styles instead of inheriting, so the stylesheet applies the
221     * variables to the titles and the header heading only when a value is set.
222     *
223     * @param mixed $block_attributes Block attributes.
224     *
225     * @return array{classes: string[], style: string}
226     */
227    public static function block_style_vars( $block_attributes ) {
228        $block_attributes = is_array( $block_attributes ) ? $block_attributes : array();
229        $style_attribute  = isset( $block_attributes['style'] ) && is_array( $block_attributes['style'] ) ? $block_attributes['style'] : array();
230        $typography       = isset( $style_attribute['typography'] ) && is_array( $style_attribute['typography'] ) ? $style_attribute['typography'] : array();
231        $color            = isset( $style_attribute['color'] ) && is_array( $style_attribute['color'] ) ? $style_attribute['color'] : array();
232
233        $preset = function ( $type, $slug ) {
234            return is_string( $slug ) && preg_match( '/^[a-z0-9-]+$/i', $slug )
235                ? 'var(--wp--preset--' . $type . '--' . strtolower( $slug ) . ')'
236                : null;
237        };
238
239        $values = array(
240            'font-family'     => $preset( 'font-family', $block_attributes['fontFamily'] ?? null ) ?? ( $typography['fontFamily'] ?? null ),
241            'font-size'       => $preset( 'font-size', $block_attributes['fontSize'] ?? null ) ?? ( $typography['fontSize'] ?? null ),
242            'font-style'      => $typography['fontStyle'] ?? null,
243            'font-weight'     => $typography['fontWeight'] ?? null,
244            'line-height'     => $typography['lineHeight'] ?? null,
245            'letter-spacing'  => $typography['letterSpacing'] ?? null,
246            'text-transform'  => $typography['textTransform'] ?? null,
247            'text-decoration' => $typography['textDecoration'] ?? null,
248            'color'           => $preset( 'color', $block_attributes['textColor'] ?? null ) ?? ( $color['text'] ?? null ),
249        );
250
251        $classes = array();
252        $style   = '';
253        foreach ( $values as $property => $value ) {
254            $value = self::sanitize_style_value( $value );
255            if ( null === $value ) {
256                continue;
257            }
258            $classes[] = 'has-vpap-' . $property;
259            $style    .= '--vpap-' . $property . ':' . $value . ';';
260        }
261
262        return array(
263            'classes' => $classes,
264            'style'   => $style,
265        );
266    }
267
268    /**
269     * Keep a style value to what a font or color setting can hold: presets
270     * (including the `var:preset|type|slug` notation) and plain CSS values.
271     *
272     * @param mixed $value Raw attribute value.
273     *
274     * @return string|null The value, or null when it is empty or unsafe.
275     */
276    private static function sanitize_style_value( $value ) {
277        if ( is_int( $value ) || is_float( $value ) ) {
278            $value = (string) $value;
279        }
280        if ( ! is_string( $value ) ) {
281            return null;
282        }
283
284        $value = trim( $value );
285        if ( preg_match( '/^var:preset\|([a-z0-9-]+)\|([a-z0-9-]+)$/i', $value, $matches ) ) {
286            $value = 'var(--wp--preset--' . strtolower( $matches[1] ) . '--' . strtolower( $matches[2] ) . ')';
287        }
288
289        if ( '' === $value
290            || ! preg_match( '/^[A-Za-z0-9 ,.%#\/+"\'()-]+$/', $value )
291            || preg_match( '/(url|expression|image)\s*\(/i', $value )
292        ) {
293            return null;
294        }
295
296        return $value;
297    }
298
299    /**
300     * The page a numbered pagination is showing, from the request.
301     *
302     * @return int 1-based page number.
303     */
304    private static function requested_page() {
305        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination argument, like core's paged.
306        $page = isset( $_GET[ self::PAGE_QUERY_ARG ] ) ? absint( wp_unslash( $_GET[ self::PAGE_QUERY_ARG ] ) ) : 1;
307
308        return max( 1, $page );
309    }
310
311    /**
312     * Render callback.
313     *
314     * @param array  $block_attributes Block attributes.
315     * @param string $content          The rendered inner blocks: the heading the user edits in the editor.
316     *
317     * @return string Block markup, or an empty string when the site has no playlists.
318     */
319    public static function render( $block_attributes, $content = '' ) {
320        $settings  = self::settings( $block_attributes );
321        $playlists = self::ordered_playlists( $settings['order_by'] );
322        $total     = count( $playlists );
323        if ( ! $total ) {
324            return '';
325        }
326
327        $per_page     = $settings['per_page'];
328        $pages        = (int) ceil( $total / $per_page );
329        $is_load_more = 'load-more' === $settings['pagination'];
330        $current_page = $is_load_more ? 1 : min( $pages, self::requested_page() );
331
332        $items = '';
333        foreach ( $playlists as $index => $playlist ) {
334            $page = (int) floor( $index / $per_page ) + 1;
335            if ( ! $is_load_more && $page !== $current_page ) {
336                continue;
337            }
338            $items .= self::render_item( $playlist, $settings, $page, $is_load_more && $page > 1 );
339        }
340
341        if ( $is_load_more ) {
342            $shown   = min( $per_page, $total );
343            $summary = sprintf(
344                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
345                __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
346                number_format_i18n( $shown ),
347                number_format_i18n( $total )
348            );
349            $pagination = $total > $shown ? self::render_load_more( $total - $shown, $per_page ) : '';
350        } else {
351            $summary = sprintf(
352                /* translators: %s: number of playlists on the site. */
353                _n( '%s playlist', '%s playlists', $total, 'jetpack-videopress-pkg' ),
354                number_format_i18n( $total )
355            );
356            $pagination = $pages > 1 ? self::render_numbered_pagination( $current_page, $pages ) : '';
357        }
358
359        $style_vars = self::block_style_vars( $block_attributes );
360        $classes    = array_merge(
361            array(
362                'videopress-all-playlists',
363                'is-layout-' . $settings['layout'],
364                'is-pagination-' . $settings['pagination'],
365            ),
366            $style_vars['classes']
367        );
368
369        $wrapper_attributes = get_block_wrapper_attributes(
370            array(
371                'class'               => implode( ' ', $classes ),
372                'style'               => '--vpap-columns:' . $settings['columns'] . ';' . $style_vars['style'],
373                'data-playlist-total' => (string) $total,
374                'data-per-page'       => (string) $per_page,
375                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
376                'data-summary'        => __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
377            )
378        );
379
380        return sprintf(
381            '<div %1$s>' .
382                '<div class="videopress-all-playlists__header">%2$s<span class="videopress-all-playlists__summary">%3$s</span></div>' .
383                '<ul class="videopress-all-playlists__items">%4$s</ul>%5$s' .
384            '</div>',
385            $wrapper_attributes,
386            self::render_heading( $content ),
387            esc_html( $summary ),
388            $items,
389            $pagination
390        );
391    }
392
393    /**
394     * The header's heading: the core Heading block saved with the post, or a
395     * plain fallback for content saved before the heading became editable.
396     *
397     * @param string $content Rendered inner blocks.
398     *
399     * @return string Heading markup.
400     */
401    private static function render_heading( $content ) {
402        $content = is_string( $content ) ? trim( $content ) : '';
403        if ( '' !== $content ) {
404            return $content;
405        }
406
407        return sprintf(
408            '<h2 class="videopress-all-playlists__heading">%s</h2>',
409            esc_html__( 'Playlists', 'jetpack-videopress-pkg' )
410        );
411    }
412
413    /**
414     * Render one playlist card.
415     *
416     * @param array $playlist A record from ordered_playlists().
417     * @param array $settings Normalized settings.
418     * @param int   $page     The page the card belongs to.
419     * @param bool  $hidden   Whether the card starts hidden, waiting for "Load more".
420     *
421     * @return string Card markup.
422     */
423    private static function render_item( $playlist, $settings, $page, $hidden ) {
424        $title       = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
425        $permalink   = $playlist['post'] ? get_permalink( $playlist['post'] ) : '';
426        $video_count = count( $playlist['videos'] );
427        $first_guid  = $video_count && isset( $playlist['videos'][0]['guid'] ) && is_string( $playlist['videos'][0]['guid'] )
428            ? $playlist['videos'][0]['guid']
429            : '';
430
431        $item_classes   = array( 'videopress-all-playlists__item' );
432        $item_classes[] = $first_guid ? 'is-poster-loading' : 'is-poster-missing';
433
434        $badge = '';
435        if ( $settings['show_video_count'] ) {
436            $badge = sprintf(
437                '<span class="videopress-all-playlists__badge"><span class="videopress-all-playlists__badge-icon" aria-hidden="true">≡</span>%s</span>',
438                esc_html(
439                    sprintf(
440                        /* translators: %s: number of videos in the playlist. */
441                        _n( '%s video', '%s videos', $video_count, 'jetpack-videopress-pkg' ),
442                        number_format_i18n( $video_count )
443                    )
444                )
445            );
446        }
447
448        $poster = sprintf(
449            '<span class="videopress-all-playlists__deck" aria-hidden="true"></span>' .
450            '<span class="videopress-all-playlists__poster-frame">' .
451                '<img class="videopress-all-playlists__poster-image" alt="" loading="lazy" hidden />' .
452                '<span class="videopress-all-playlists__poster-missing">%1$s</span>%2$s' .
453            '</span>',
454            esc_html__( 'No poster available', 'jetpack-videopress-pkg' ),
455            $badge
456        );
457        $poster = $permalink
458            ? sprintf(
459                '<a class="videopress-all-playlists__poster" href="%1$s" aria-label="%2$s"%3$s>%4$s</a>',
460                esc_url( $permalink ),
461                esc_attr( $title ),
462                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
463                $poster
464            )
465            : sprintf(
466                '<span class="videopress-all-playlists__poster"%1$s>%2$s</span>',
467                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
468                $poster
469            );
470
471        $body = sprintf(
472            '<h3 class="videopress-all-playlists__title">%s</h3>',
473            $permalink
474                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $permalink ), esc_html( $title ) )
475                : esc_html( $title )
476        );
477        if ( $settings['show_description'] && '' !== $playlist['description'] ) {
478            $body .= sprintf( '<p class="videopress-all-playlists__description">%s</p>', esc_html( $playlist['description'] ) );
479        }
480        if ( $settings['show_updated'] ) {
481            $meta = self::updated_meta( $playlist );
482            if ( '' !== $meta ) {
483                $body .= sprintf( '<span class="videopress-all-playlists__meta">%s</span>', esc_html( $meta ) );
484            }
485        }
486        $body .= sprintf(
487            '<span class="videopress-all-playlists__poster-note">%s</span>',
488            esc_html__( 'First video is private or was deleted.', 'jetpack-videopress-pkg' )
489        );
490        if ( $permalink ) {
491            $body .= sprintf(
492                '<a class="videopress-all-playlists__link" href="%1$s">%2$s</a>',
493                esc_url( $permalink ),
494                esc_html__( 'View full playlist →', 'jetpack-videopress-pkg' )
495            );
496        }
497
498        return sprintf(
499            '<li class="%1$s" data-playlist="%2$s" data-page="%3$d"%4$s>%5$s<div class="videopress-all-playlists__body">%6$s</div></li>',
500            esc_attr( implode( ' ', $item_classes ) ),
501            esc_attr( $playlist['key'] ),
502            $page,
503            $hidden ? ' hidden' : '',
504            $poster,
505            $body
506        );
507    }
508
509    /**
510     * "Updated 2 weeks ago · 1 hr 12 min" for a card.
511     *
512     * @param array $playlist A record from ordered_playlists().
513     *
514     * @return string
515     */
516    public static function updated_meta( $playlist ) {
517        $parts = array();
518        if ( $playlist['post'] instanceof WP_Post ) {
519            $ago = Channel_Data::ago( (int) strtotime( $playlist['post']->post_modified_gmt . ' UTC' ) );
520            if ( '' !== $ago ) {
521                /* translators: %s: how long ago, e.g. "2 weeks ago". */
522                $parts[] = sprintf( __( 'Updated %s', 'jetpack-videopress-pkg' ), $ago );
523            }
524        }
525        $total_ms = 0;
526        foreach ( Initializer::sanitize_playlist_entries( $playlist['videos'] ) as $entry ) {
527            $total_ms += $entry['durationMs'];
528        }
529        $runtime = Initializer::playlist_runtime_label( $total_ms );
530        if ( '' !== $runtime ) {
531            $parts[] = $runtime;
532        }
533        return implode( ' · ', $parts );
534    }
535
536    /**
537     * Render the numbered pagination.
538     *
539     * @param int $current Current page.
540     * @param int $pages   Number of pages.
541     *
542     * @return string Pagination markup.
543     */
544    private static function render_numbered_pagination( $current, $pages ) {
545        $url_for = function ( $page ) {
546            return 1 === $page
547                ? remove_query_arg( self::PAGE_QUERY_ARG )
548                : add_query_arg( self::PAGE_QUERY_ARG, $page );
549        };
550
551        $links = $current > 1
552            ? sprintf(
553                '<a class="videopress-all-playlists__page videopress-all-playlists__page--prev" href="%1$s">%2$s</a>',
554                esc_url( $url_for( $current - 1 ) ),
555                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
556            )
557            : sprintf(
558                '<span class="videopress-all-playlists__page videopress-all-playlists__page--prev is-disabled" aria-disabled="true">%s</span>',
559                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
560            );
561
562        for ( $page = 1; $page <= $pages; $page++ ) {
563            $links .= $page === $current
564                ? sprintf(
565                    '<span class="videopress-all-playlists__page videopress-all-playlists__page--number is-current" aria-current="page">%d</span>',
566                    $page
567                )
568                : sprintf(
569                    '<a class="videopress-all-playlists__page videopress-all-playlists__page--number" href="%1$s">%2$d</a>',
570                    esc_url( $url_for( $page ) ),
571                    $page
572                );
573        }
574
575        $links .= $current < $pages
576            ? sprintf(
577                '<a class="videopress-all-playlists__page videopress-all-playlists__page--next" href="%1$s">%2$s</a>',
578                esc_url( $url_for( $current + 1 ) ),
579                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
580            )
581            : sprintf(
582                '<span class="videopress-all-playlists__page videopress-all-playlists__page--next is-disabled" aria-disabled="true">%s</span>',
583                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
584            );
585
586        return sprintf(
587            '<nav class="videopress-all-playlists__pagination" aria-label="%1$s">%2$s</nav>',
588            esc_attr__( 'Playlists pagination', 'jetpack-videopress-pkg' ),
589            $links
590        );
591    }
592
593    /**
594     * Render the "Load more" button.
595     *
596     * @param int $remaining Playlists not shown yet.
597     * @param int $per_page  Playlists revealed per click.
598     *
599     * @return string Button markup.
600     */
601    private static function render_load_more( $remaining, $per_page ) {
602        /* translators: %s: number of playlists the button reveals. */
603        $label = __( 'Load %s more', 'jetpack-videopress-pkg' );
604
605        return sprintf(
606            '<div class="videopress-all-playlists__load-more"><button type="button" class="videopress-all-playlists__load-more-button" data-label="%1$s">%2$s</button></div>',
607            esc_attr( $label ),
608            esc_html( sprintf( $label, number_format_i18n( min( $remaining, $per_page ) ) ) )
609        );
610    }
611}