diff --git a/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block b/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block
new file mode 100644
index 000000000000..9de10bdfbdea
--- /dev/null
+++ b/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add a VideoPress playlist block that stores a list of video GUIDs and plays them in sequence.
diff --git a/projects/packages/videopress/src/class-initializer.php b/projects/packages/videopress/src/class-initializer.php
index 0114d6a8a300..c13b6391d1c6 100644
--- a/projects/packages/videopress/src/class-initializer.php
+++ b/projects/packages/videopress/src/class-initializer.php
@@ -269,6 +269,9 @@ public static function video_enqueue_bridge_when_oembed_present( $cache, $url, $
public static function register_videopress_blocks() {
// Register VideoPress Video block.
self::register_videopress_video_block();
+
+ // Register VideoPress Playlist block.
+ self::register_videopress_playlist_block();
}
/**
@@ -552,6 +555,292 @@ public static function register_videopress_video_block() {
Block_Editor_Extensions::init( $script_handle );
}
+ /**
+ * Register the VideoPress playlist block.
+ *
+ * @param string|null $metadata_file Path to the block.json metadata file.
+ * Defaults to the package build output; tests can point at a fixture.
+ *
+ * @return void
+ */
+ public static function register_videopress_playlist_block( $metadata_file = null ) {
+ /*
+ * Unlike the video block, the playlist block has no "activate the module"
+ * placeholder, so don't register it at all when VideoPress isn't available.
+ */
+ if (
+ Status::is_jetpack_plugin_without_videopress_module_active()
+ && ! Status::is_standalone_plugin_active()
+ ) {
+ return;
+ }
+
+ if ( null === $metadata_file ) {
+ $metadata_file = __DIR__ . '/../build/block-editor/blocks/playlist/block.json';
+ }
+
+ if ( ! file_exists( $metadata_file ) ) {
+ return;
+ }
+
+ $metadata = json_decode(
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ file_get_contents( $metadata_file )
+ );
+
+ if ( empty( $metadata->name ) ) {
+ return;
+ }
+
+ // Do not register if the block is already registered.
+ if ( \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name ) ) {
+ return;
+ }
+
+ register_block_type(
+ $metadata_file,
+ array(
+ 'render_callback' => array( __CLASS__, 'render_videopress_playlist_block' ),
+ )
+ );
+ }
+
+ /**
+ * Build the VideoPress embed URL for a playlist entry.
+ *
+ * @param string $guid Video GUID.
+ * @param bool $autoplay Whether the video should start playing as soon as it loads.
+ *
+ * @return string Embed URL.
+ */
+ private static function get_playlist_embed_url( $guid, $autoplay ) {
+ return add_query_arg(
+ array(
+ 'cover' => 1,
+ 'autoPlay' => (int) $autoplay,
+ 'preloadContent' => 'metadata',
+ ),
+ 'https://videopress.com/embed/' . rawurlencode( $guid )
+ );
+ }
+
+ /**
+ * Format a millisecond duration as m:ss or h:mm:ss.
+ *
+ * @param int $duration_ms Duration in milliseconds.
+ *
+ * @return string Formatted duration, or an empty string for unusable input.
+ */
+ private static function format_playlist_duration( $duration_ms ) {
+ if ( ! is_numeric( $duration_ms ) || $duration_ms <= 0 ) {
+ return '';
+ }
+
+ $total_seconds = (int) round( $duration_ms / 1000 );
+ $hours = (int) floor( $total_seconds / 3600 );
+ $minutes = (int) floor( ( $total_seconds % 3600 ) / 60 );
+ $seconds = $total_seconds % 60;
+
+ if ( $hours > 0 ) {
+ return sprintf( '%d:%02d:%02d', $hours, $minutes, $seconds );
+ }
+
+ return sprintf( '%d:%02d', $minutes, $seconds );
+ }
+
+ /**
+ * Format a millisecond duration as a long runtime, e.g. "1 hr 13 min".
+ *
+ * @param int $duration_ms Duration in milliseconds.
+ *
+ * @return string Formatted runtime, or an empty string for unusable input.
+ */
+ private static function format_playlist_runtime( $duration_ms ) {
+ if ( ! is_numeric( $duration_ms ) || $duration_ms <= 0 ) {
+ return '';
+ }
+
+ $total_minutes = max( 1, (int) round( $duration_ms / 60000 ) );
+ $hours = (int) floor( $total_minutes / 60 );
+ $minutes = $total_minutes % 60;
+
+ if ( $hours > 0 ) {
+ return $minutes > 0
+ /* translators: 1: hours, 2: minutes. */
+ ? sprintf( __( '%1$d hr %2$d min', 'jetpack-videopress-pkg' ), $hours, $minutes )
+ /* translators: %d: hours. */
+ : sprintf( __( '%d hr', 'jetpack-videopress-pkg' ), $hours );
+ }
+
+ /* translators: %d: minutes. */
+ return sprintf( __( '%d min', 'jetpack-videopress-pkg' ), $minutes );
+ }
+
+ /**
+ * Map a video height to a quality/resolution badge label.
+ *
+ * @param int $height Video height in pixels.
+ *
+ * @return string Badge label, or an empty string for unusable input.
+ */
+ private static function playlist_quality_label( $height ) {
+ if ( ! is_numeric( $height ) || $height <= 0 ) {
+ return '';
+ }
+
+ return $height >= 2160 ? '4K' : ( (int) $height ) . 'p';
+ }
+
+ /**
+ * VideoPress playlist block render method.
+ *
+ * @param array $block_attributes Block attributes.
+ *
+ * @return string Block markup.
+ */
+ public static function render_videopress_playlist_block( $block_attributes ) {
+ $videos = array();
+
+ if ( isset( $block_attributes['videos'] ) && is_array( $block_attributes['videos'] ) ) {
+ foreach ( $block_attributes['videos'] as $video ) {
+ if ( ! is_array( $video ) || empty( $video['guid'] ) || ! is_string( $video['guid'] ) ) {
+ continue;
+ }
+
+ // VideoPress GUIDs are 8 alphanumeric characters; drop anything else.
+ if ( ! preg_match( '/^[a-z\d]{8}$/i', $video['guid'] ) ) {
+ continue;
+ }
+
+ $videos[] = array(
+ 'guid' => $video['guid'],
+ 'title' => isset( $video['title'] ) && is_string( $video['title'] ) ? $video['title'] : '',
+ 'durationMs' => isset( $video['durationMs'] ) && is_numeric( $video['durationMs'] ) ? (int) $video['durationMs'] : 0,
+ 'height' => isset( $video['height'] ) && is_numeric( $video['height'] ) ? (int) $video['height'] : 0,
+ 'poster' => isset( $video['poster'] ) && is_string( $video['poster'] ) ? $video['poster'] : '',
+ );
+ }
+ }
+
+ if ( ! $videos ) {
+ return '';
+ }
+
+ $auto_advance = ! isset( $block_attributes['autoAdvance'] ) || $block_attributes['autoAdvance'];
+ $loop = ! empty( $block_attributes['loop'] );
+
+ $layout = isset( $block_attributes['layout'] ) && in_array( $block_attributes['layout'], array( 'rail', 'grid', 'strip' ), true )
+ ? $block_attributes['layout']
+ : 'rail';
+
+ $show = function ( $key ) use ( $block_attributes ) {
+ return ! isset( $block_attributes[ $key ] ) || $block_attributes[ $key ];
+ };
+
+ $wrapper_classes = array( 'videopress-playlist--' . $layout );
+ if ( ! empty( $block_attributes['darkSurface'] ) ) {
+ $wrapper_classes[] = 'is-dark';
+ }
+ if ( ! $show( 'showThumbnail' ) ) {
+ $wrapper_classes[] = 'hide-thumbnails';
+ }
+ if ( ! $show( 'showTitle' ) ) {
+ $wrapper_classes[] = 'hide-titles';
+ }
+ if ( ! $show( 'showResolution' ) ) {
+ $wrapper_classes[] = 'hide-resolution';
+ }
+ if ( ! $show( 'showDuration' ) ) {
+ $wrapper_classes[] = 'hide-duration';
+ }
+ if ( ! empty( $block_attributes['showPosition'] ) ) {
+ $wrapper_classes[] = 'show-position';
+ }
+ if ( ! $show( 'showTotalRuntime' ) ) {
+ $wrapper_classes[] = 'hide-runtime';
+ }
+
+ // The JWT token bridge lets the embed play private videos for authorized viewers.
+ Jwt_Token_Bridge::enqueue_jwt_token_bridge();
+
+ /*
+ * Stored metadata is initial content only; the view script refreshes
+ * title, thumbnail, duration, and quality from the VideoPress API on
+ * the client so entries always reflect the videos' current data.
+ */
+ $items_markup = '';
+ $total_ms = 0;
+ foreach ( $videos as $index => $video ) {
+ $title = '' !== $video['title']
+ ? $video['title']
+ /* translators: %d: position of the video in the playlist. */
+ : sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 );
+
+ $duration = self::format_playlist_duration( $video['durationMs'] );
+ $total_ms += $video['durationMs'];
+
+ $thumb_markup = '' !== $video['poster']
+ ? sprintf( '
', esc_url( $video['poster'] ) )
+ : '';
+
+ $items_markup .= sprintf(
+ '
',
+ 0 === $index ? ' is-current' : '',
+ esc_attr( $video['guid'] ),
+ esc_url( self::get_playlist_embed_url( $video['guid'], true ) ),
+ $video['durationMs'],
+ 0 === $index ? 'true' : 'false',
+ $thumb_markup,
+ $index + 1,
+ esc_html( $duration ),
+ esc_html( $title ),
+ esc_html( self::playlist_quality_label( $video['height'] ) ),
+ esc_html( $duration )
+ );
+ }
+
+ $runtime = self::format_playlist_runtime( $total_ms );
+ $header_markup = sprintf(
+ '',
+ esc_html(
+ sprintf(
+ /* translators: %d: number of videos in the playlist. */
+ _n( '%d video', '%d videos', count( $videos ), 'jetpack-videopress-pkg' ),
+ count( $videos )
+ )
+ ),
+ esc_html( $runtime )
+ );
+
+ $player_markup = sprintf(
+ '',
+ esc_attr__( 'VideoPress Playlist Player', 'jetpack-videopress-pkg' ),
+ esc_url( self::get_playlist_embed_url( $videos[0]['guid'], false ) ),
+ esc_attr( $videos[0]['guid'] )
+ );
+
+ $wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'class' => implode( ' ', $wrapper_classes ),
+ 'data-auto-advance' => $auto_advance ? '1' : '0',
+ 'data-loop' => $loop ? '1' : '0',
+ )
+ );
+
+ // An unordered list: item numbering comes from the index spans, so theme
+ // list styles can never double up the numbers.
+ return sprintf(
+ '%2$s',
+ $wrapper_attributes,
+ $header_markup,
+ $player_markup,
+ $items_markup
+ );
+ }
+
/**
* Enqueue the VideoPress Iframe API script
* when the URL of oEmbed HTML is a VideoPress URL.
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json b/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json
new file mode 100644
index 000000000000..195fff96dcc7
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json
@@ -0,0 +1,75 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "videopress/playlist",
+ "version": "0.1.0",
+ "title": "VideoPress Playlist",
+ "category": "media",
+ "icon": "",
+ "description": "Play a list of VideoPress videos in sequence.",
+ "keywords": [ "playlist", "video", "videopress" ],
+ "supports": {
+ "html": false,
+ "align": true,
+ "anchor": true,
+ "spacing": {
+ "margin": true,
+ "padding": true
+ }
+ },
+ "attributes": {
+ "videos": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "default": []
+ },
+ "autoAdvance": {
+ "type": "boolean",
+ "default": true
+ },
+ "loop": {
+ "type": "boolean",
+ "default": false
+ },
+ "layout": {
+ "type": "string",
+ "enum": [ "rail", "grid", "strip" ],
+ "default": "rail"
+ },
+ "darkSurface": {
+ "type": "boolean",
+ "default": false
+ },
+ "showThumbnail": {
+ "type": "boolean",
+ "default": true
+ },
+ "showTitle": {
+ "type": "boolean",
+ "default": true
+ },
+ "showResolution": {
+ "type": "boolean",
+ "default": true
+ },
+ "showDuration": {
+ "type": "boolean",
+ "default": true
+ },
+ "showPosition": {
+ "type": "boolean",
+ "default": false
+ },
+ "showTotalRuntime": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "textdomain": "jetpack-videopress-pkg",
+ "editorScript": "file:./index.js",
+ "editorStyle": [ "file:./index.css", "file:./view.css" ],
+ "viewScript": "file:./view.js",
+ "viewStyle": "file:./view.css"
+}
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx
new file mode 100644
index 000000000000..b4543e9871c3
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx
@@ -0,0 +1,783 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ InspectorControls,
+ MediaUpload,
+ MediaUploadCheck,
+ useBlockProps,
+} from '@wordpress/block-editor';
+import {
+ Button,
+ Notice,
+ PanelBody,
+ Placeholder,
+ TextControl,
+ ToggleControl,
+} from '@wordpress/components';
+import { useEffect, useRef, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { closeSmall, dragHandle, Icon } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { fetchVideoItem } from '../../../lib/fetch-video-item';
+import { isVideoPressGuid, pickGUIDFromUrl } from '../../../lib/url';
+import { VideoPressIcon } from '../video/components/icons';
+import { VIDEOPRESS_VIDEO_ALLOWED_MEDIA_TYPES } from '../video/constants';
+import { formatDuration, formatRuntimeLong, qualityLabel, totalDurationMs } from './utils';
+import './editor.scss';
+/**
+ * Types
+ */
+import type { PlaylistBlockAttributes, PlaylistLayout, PlaylistVideo } from './types';
+import type { AdminAjaxQueryAttachmentsResponseItemProps } from '../../../types';
+import type { BlockEditProps } from '@wordpress/blocks';
+
+const LAYOUT_OPTIONS: Array< { value: PlaylistLayout; label: string } > = [
+ { value: 'rail', label: __( 'Side rail', 'jetpack-videopress-pkg' ) },
+ { value: 'grid', label: __( 'Grid', 'jetpack-videopress-pkg' ) },
+ { value: 'strip', label: __( 'Strip', 'jetpack-videopress-pkg' ) },
+];
+
+/**
+ * Extract a VideoPress GUID from user input, which can be
+ * either a bare GUID or any recognized VideoPress URL.
+ *
+ * @param value - Raw user input.
+ * @return The GUID, or null when the input is not recognized.
+ */
+function parseVideoInput( value: string ): string | null {
+ const trimmed = value.trim();
+ if ( ! trimmed ) {
+ return null;
+ }
+
+ const guid = isVideoPressGuid( trimmed );
+ if ( guid ) {
+ return guid as string;
+ }
+
+ return pickGUIDFromUrl( trimmed );
+}
+
+/**
+ * Build a playlist entry's metadata fields from a videos API response.
+ *
+ * @param videoItem - The API response for one video.
+ * @return Metadata fields present in the response.
+ */
+function metadataFromVideoItem(
+ videoItem: Record< string, unknown >
+): Omit< PlaylistVideo, 'guid' > {
+ const metadata: Omit< PlaylistVideo, 'guid' > = {};
+
+ if ( typeof videoItem?.title === 'string' && videoItem.title !== '' ) {
+ metadata.title = decodeEntities( videoItem.title );
+ }
+ if ( typeof videoItem?.duration === 'number' && videoItem.duration > 0 ) {
+ metadata.durationMs = videoItem.duration;
+ }
+ if ( typeof videoItem?.height === 'number' && videoItem.height > 0 ) {
+ metadata.height = videoItem.height;
+ }
+ if ( typeof videoItem?.poster === 'string' && videoItem.poster !== '' ) {
+ metadata.poster = videoItem.poster;
+ }
+
+ return metadata;
+}
+
+/**
+ * Format the "resolution · duration" meta line for a playlist entry.
+ *
+ * @param video - Playlist entry.
+ * @return Meta line, possibly empty.
+ */
+function metaLine( video: PlaylistVideo ): string {
+ return [ qualityLabel( video.height ), formatDuration( video.durationMs ) ]
+ .filter( Boolean )
+ .join( ' · ' );
+}
+
+/**
+ * VideoPress Playlist block Edit component.
+ *
+ * @param props - Block edit props.
+ * @param props.attributes - Block attributes.
+ * @param props.setAttributes - Attributes setter.
+ * @return React component.
+ */
+export default function PlaylistBlockEdit( {
+ attributes,
+ setAttributes,
+}: BlockEditProps< PlaylistBlockAttributes > ) {
+ const {
+ videos,
+ autoAdvance,
+ loop,
+ layout,
+ darkSurface,
+ showThumbnail,
+ showTitle,
+ showResolution,
+ showDuration,
+ showPosition,
+ showTotalRuntime,
+ } = attributes;
+ const [ currentIndex, setCurrentIndex ] = useState( 0 );
+ const [ newVideoInput, setNewVideoInput ] = useState( '' );
+ const [ errorNotice, setErrorNotice ] = useState< string | null >( null );
+ const [ isAddingVideo, setIsAddingVideo ] = useState( false );
+ const [ draggedIndex, setDraggedIndex ] = useState< number | null >( null );
+ const [ dropTargetIndex, setDropTargetIndex ] = useState< number | null >( null );
+ const [ filterText, setFilterText ] = useState( '' );
+ const [ pendingDuplicate, setPendingDuplicate ] = useState< PlaylistVideo | null >( null );
+
+ // Always points at the latest videos so async metadata fetches never clobber newer edits.
+ const videosRef = useRef( videos );
+ videosRef.current = videos;
+
+ // GUIDs with a metadata refresh already started this editor session; they are
+ // not re-fetched, so a failed lookup simply keeps the stored fields.
+ const metadataFetchesStarted = useRef( new Set< string >() );
+
+ // Entry metadata always mirrors the video data: every entry is refreshed once
+ // per editor session, and stored fields are replaced whenever they differ.
+ useEffect( () => {
+ videos.forEach( video => {
+ if ( metadataFetchesStarted.current.has( video.guid ) ) {
+ return;
+ }
+
+ metadataFetchesStarted.current.add( video.guid );
+
+ fetchVideoItem( { guid: video.guid, isPrivate: false, skipRatingControl: true } )
+ .then( videoItem => {
+ const metadata = metadataFromVideoItem( videoItem as Record< string, unknown > );
+ if ( ! Object.keys( metadata ).length ) {
+ return;
+ }
+
+ const current = videosRef.current;
+ const needsUpdate = current.some(
+ entry =>
+ entry.guid === video.guid &&
+ Object.entries( metadata ).some(
+ ( [ key, value ] ) => entry[ key as keyof PlaylistVideo ] !== value
+ )
+ );
+
+ if ( ! needsUpdate ) {
+ return;
+ }
+
+ setAttributes( {
+ videos: current.map( entry =>
+ entry.guid === video.guid ? { ...entry, ...metadata } : entry
+ ),
+ } );
+ } )
+ .catch( () => {
+ // Keep the stored fields when the video data isn't reachable.
+ } );
+ } );
+ }, [ videos, setAttributes ] );
+
+ const wrapperClasses = [
+ 'videopress-playlist-editor',
+ `videopress-playlist--${ layout }`,
+ darkSurface ? 'is-dark' : '',
+ showThumbnail ? '' : 'hide-thumbnails',
+ showTitle ? '' : 'hide-titles',
+ showResolution ? '' : 'hide-resolution',
+ showDuration ? '' : 'hide-duration',
+ showPosition ? 'show-position' : '',
+ showTotalRuntime ? '' : 'hide-runtime',
+ ]
+ .filter( Boolean )
+ .join( ' ' );
+
+ const blockProps = useBlockProps( { className: wrapperClasses } );
+
+ const currentVideo = videos[ currentIndex ] ?? videos[ 0 ];
+ const runtime = formatRuntimeLong( totalDurationMs( videos ) );
+
+ const performAdd = async ( guid: string ) => {
+ setIsAddingVideo( true );
+
+ // All entry data comes from the video itself; none of it is editable here.
+ let metadata: Omit< PlaylistVideo, 'guid' > = {};
+ try {
+ const videoItem = await fetchVideoItem( { guid, isPrivate: false, skipRatingControl: true } );
+ metadata = metadataFromVideoItem( videoItem as Record< string, unknown > );
+ // Fresh from the video data; no need for the refresh effect to re-fetch it.
+ metadataFetchesStarted.current.add( guid );
+ } catch {
+ // The entry still works without metadata; the list shows the GUID and
+ // the refresh effect retries once more.
+ }
+
+ setAttributes( { videos: [ ...videosRef.current, { guid, ...metadata } ] } );
+ setNewVideoInput( '' );
+ setErrorNotice( null );
+ setPendingDuplicate( null );
+ setIsAddingVideo( false );
+ };
+
+ const addVideo = async () => {
+ if ( isAddingVideo ) {
+ return;
+ }
+
+ const guid = parseVideoInput( newVideoInput );
+ if ( ! guid ) {
+ setErrorNotice(
+ __(
+ 'No video found at that link. Paste a VideoPress video URL or GUID.',
+ 'jetpack-videopress-pkg'
+ )
+ );
+ return;
+ }
+
+ const existing = videos.find( video => video.guid === guid );
+ if ( existing ) {
+ setPendingDuplicate( existing );
+ return;
+ }
+
+ await performAdd( guid );
+ };
+
+ const addVideosFromLibrary = (
+ selection:
+ | AdminAjaxQueryAttachmentsResponseItemProps
+ | AdminAjaxQueryAttachmentsResponseItemProps[]
+ ) => {
+ const mediaItems = Array.isArray( selection ) ? selection : [ selection ];
+
+ const libraryVideos: PlaylistVideo[] = [];
+ for ( const media of mediaItems ) {
+ // Depending on the endpoint, `videopress_guid` can be an array or a string.
+ const guid = Array.isArray( media?.videopress_guid )
+ ? media.videopress_guid[ 0 ]
+ : media?.videopress_guid;
+
+ if ( ! guid ) {
+ continue;
+ }
+
+ const poster = media.image?.src ?? media.thumb?.src;
+
+ libraryVideos.push( {
+ guid,
+ ...( typeof media.title === 'string' && media.title !== '' && { title: media.title } ),
+ ...( typeof poster === 'string' && poster !== '' && { poster } ),
+ } );
+ }
+
+ if ( ! libraryVideos.length ) {
+ setErrorNotice(
+ __(
+ 'None of the selected items are VideoPress videos. Choose videos hosted on VideoPress.',
+ 'jetpack-videopress-pkg'
+ )
+ );
+ return;
+ }
+
+ setAttributes( { videos: [ ...videos, ...libraryVideos ] } );
+ setErrorNotice( null );
+ };
+
+ const removeVideo = ( index: number ) => {
+ setAttributes( { videos: videos.filter( ( _, i ) => i !== index ) } );
+ if ( currentIndex >= index && currentIndex > 0 ) {
+ setCurrentIndex( currentIndex - 1 );
+ }
+ };
+
+ const reorderVideo = ( from: number, to: number ) => {
+ if ( from === to || from < 0 || to < 0 || from >= videos.length || to >= videos.length ) {
+ return;
+ }
+
+ const reordered = [ ...videos ];
+ const [ moved ] = reordered.splice( from, 1 );
+ reordered.splice( to, 0, moved );
+ setAttributes( { videos: reordered } );
+
+ // Keep the canvas preview on the same video it showed before the move.
+ if ( currentIndex === from ) {
+ setCurrentIndex( to );
+ } else if ( from < currentIndex && to >= currentIndex ) {
+ setCurrentIndex( currentIndex - 1 );
+ } else if ( from > currentIndex && to <= currentIndex ) {
+ setCurrentIndex( currentIndex + 1 );
+ }
+ };
+
+ const addUrlForm = (
+
+ {
+ setNewVideoInput( value );
+ setErrorNotice( null );
+ setPendingDuplicate( null );
+ } }
+ onKeyDown={ event => {
+ if ( event.key === 'Enter' ) {
+ event.preventDefault();
+ addVideo();
+ }
+ } }
+ />
+
+
+ );
+
+ const mediaLibraryButton = (
+
+ void } ) => (
+
+ ) }
+ />
+
+ );
+
+ // Sidebar list entries keep their original index for reorder/remove even
+ // when a filter narrows the visible set.
+ const visibleEntries = videos
+ .map( ( video: PlaylistVideo, index: number ) => ( { video, index } ) )
+ .filter( ( { video } ) => {
+ if ( ! filterText ) {
+ return true;
+ }
+ const haystack = `${ video.title ?? '' } ${ video.guid }`.toLowerCase();
+ return haystack.includes( filterText.toLowerCase() );
+ } );
+ const isFiltering = filterText !== '';
+
+ // All playlist management (add, sort, delete) lives in the settings
+ // sidebar; the canvas below is a preview of what visitors see.
+ const inspectorControls = (
+
+
+ { addUrlForm }
+
+ { __(
+ 'Any VideoPress video URL or GUID. Title, thumbnail, duration and resolution come from the video data.',
+ 'jetpack-videopress-pkg'
+ ) }
+
+ { mediaLibraryButton }
+
+ { errorNotice && (
+
+ { errorNotice }
+
+ ) }
+
+ { pendingDuplicate && (
+
+ { sprintf(
+ /* translators: %s: video title or GUID. */
+ __( '“%s” is already in this playlist', 'jetpack-videopress-pkg' ),
+ pendingDuplicate.title || pendingDuplicate.guid
+ ) }
+
+
+
+
+
+ ) }
+
+
+
+ { __( 'Playlist', 'jetpack-videopress-pkg' ) }
+
+
+ { videos.length }
+ { runtime ? ` · ${ formatDuration( totalDurationMs( videos ) ) }` : '' }
+
+
+
+ { videos.length > 8 && (
+
+ ) }
+
+ { /* A listbox: the options take focus and respond to arrow keys for reordering. */ }
+
+ { visibleEntries.map( ( { video, index } ) => {
+ const classes = [ 'videopress-playlist-editor__manage-item' ];
+ if ( index === draggedIndex ) {
+ classes.push( 'is-dragging' );
+ }
+ if ( index === dropTargetIndex && index !== draggedIndex ) {
+ classes.push( 'is-drop-target' );
+ }
+
+ return (
+ - {
+ if ( isFiltering ) {
+ return;
+ }
+ if ( event.key === 'ArrowUp' ) {
+ event.preventDefault();
+ reorderVideo( index, index - 1 );
+ } else if ( event.key === 'ArrowDown' ) {
+ event.preventDefault();
+ reorderVideo( index, index + 1 );
+ }
+ } }
+ onDragStart={ event => {
+ setDraggedIndex( index );
+ event.dataTransfer?.setData( 'text/plain', String( index ) );
+ if ( event.dataTransfer ) {
+ event.dataTransfer.effectAllowed = 'move';
+ }
+ } }
+ onDragOver={ event => {
+ event.preventDefault();
+ if ( event.dataTransfer ) {
+ event.dataTransfer.dropEffect = 'move';
+ }
+ if ( draggedIndex !== null && index !== draggedIndex ) {
+ setDropTargetIndex( index );
+ }
+ } }
+ onDragLeave={ () => {
+ if ( dropTargetIndex === index ) {
+ setDropTargetIndex( null );
+ }
+ } }
+ onDrop={ event => {
+ event.preventDefault();
+ if ( draggedIndex !== null ) {
+ reorderVideo( draggedIndex, index );
+ }
+ setDraggedIndex( null );
+ setDropTargetIndex( null );
+ } }
+ onDragEnd={ () => {
+ setDraggedIndex( null );
+ setDropTargetIndex( null );
+ } }
+ >
+
+
+
+
+ { String( index + 1 ).padStart( 2, '0' ) }
+
+
+ { video.poster &&
}
+
+
+
+ { video.title || video.guid }
+
+ { metaLine( video ) && (
+
+ { metaLine( video ) }
+
+ ) }
+
+
+ );
+ } ) }
+ { isAddingVideo && (
+ -
+
+
+
+ { __( 'Reading metadata…', 'jetpack-videopress-pkg' ) }
+
+
+
+ ) }
+
+
+ { __(
+ 'Drag to reorder, or focus an item and press ↑ / ↓. × removes the video.',
+ 'jetpack-videopress-pkg'
+ ) }
+
+
+
+
+
+ { LAYOUT_OPTIONS.map( option => (
+
+ ) ) }
+
+ setAttributes( { darkSurface: value } ) }
+ />
+ setAttributes( { autoAdvance: value } ) }
+ />
+ setAttributes( { loop: value } ) }
+ />
+
+
+
+ setAttributes( { showThumbnail: value } ) }
+ />
+ setAttributes( { showTitle: value } ) }
+ />
+ setAttributes( { showResolution: value } ) }
+ />
+ setAttributes( { showDuration: value } ) }
+ />
+ setAttributes( { showPosition: value } ) }
+ />
+ setAttributes( { showTotalRuntime: value } ) }
+ />
+
+
+ );
+
+ if ( ! videos.length ) {
+ return (
+
+ { inspectorControls }
+
+
+ { addUrlForm }
+ { errorNotice && (
+
+ { errorNotice }
+
+ ) }
+
+ { __( 'or', 'jetpack-videopress-pkg' ) }
+
+ { mediaLibraryButton }
+
+
+
+ );
+ }
+
+ return (
+
+ { inspectorControls }
+
+
+
+ { sprintf(
+ /* translators: %d: number of videos in the playlist. */
+ _n( '%d video', '%d videos', videos.length, 'jetpack-videopress-pkg' ),
+ videos.length
+ ) }
+
+ { runtime && { runtime } }
+
+
+
+ { currentVideo && (
+
+
+
+ ) }
+
+
+ { videos.map( ( video: PlaylistVideo, index: number ) => (
+ -
+
+
+ ) ) }
+
+
+
+ );
+}
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss b/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss
new file mode 100644
index 000000000000..a7471ace441d
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss
@@ -0,0 +1,211 @@
+// Canvas: layout/preview styles come from view.scss (also loaded as an
+// editor style); only editor-specific chrome lives here.
+.videopress-playlist-editor {
+
+ .videopress-playlist__item {
+ block-size: auto;
+ }
+
+ &__placeholder-form {
+ inline-size: 100%;
+ max-inline-size: 400px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ &__placeholder-divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: rgba(0, 0, 0, 0.45);
+ font-size: 11.5px;
+
+ &::before,
+ &::after {
+ content: "";
+ flex: 1;
+ block-size: 1px;
+ background: rgba(0, 0, 0, 0.12);
+ }
+ }
+}
+
+// Sidebar panel: playlist management (add, drag-to-sort, delete). These
+// elements render in the editor sidebar, outside the block wrapper.
+.videopress-playlist-editor__add-row {
+ display: flex;
+ gap: 6px;
+
+ .components-base-control {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+ }
+
+ .components-button {
+ flex-shrink: 0;
+ }
+}
+
+.videopress-playlist-editor__placeholder-form .videopress-playlist-editor__add-row {
+ inline-size: 100%;
+}
+
+.videopress-playlist-editor__add-help {
+ margin: 7px 0 0;
+ font-size: 11.5px;
+ color: rgba(0, 0, 0, 0.55);
+}
+
+.videopress-playlist-editor__add-library {
+ margin-block-start: 8px;
+
+ .components-button {
+ inline-size: 100%;
+ justify-content: center;
+ }
+}
+
+.videopress-playlist-editor__notice {
+ margin: 10px 0 0;
+}
+
+.videopress-playlist-editor__duplicate-actions {
+ display: flex;
+ gap: 8px;
+ margin-block-start: 8px;
+}
+
+.videopress-playlist-editor__list-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin: 16px 0 10px;
+}
+
+.videopress-playlist-editor__list-title {
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.videopress-playlist-editor__list-count {
+ font-size: 11.5px;
+ color: rgba(0, 0, 0, 0.55);
+ font-variant-numeric: tabular-nums;
+}
+
+.videopress-playlist-editor__filter {
+ margin-block-end: 10px;
+}
+
+.videopress-playlist-editor__manage-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.videopress-playlist-editor__manage-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 3px;
+ cursor: grab;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ &.is-dragging {
+ opacity: 0.4;
+ }
+
+ &.is-drop-target {
+ box-shadow: 0 -2px 0 0 var(--wp-admin-theme-color, #3858e9);
+ }
+
+ &.is-loading {
+ cursor: default;
+ opacity: 0.7;
+ }
+
+ .videopress-playlist-editor__manage-item-handle {
+ display: flex;
+ flex-shrink: 0;
+ opacity: 0.6;
+ }
+
+ .videopress-playlist-editor__manage-item-index {
+ flex-shrink: 0;
+ font-size: 10.5px;
+ color: rgba(0, 0, 0, 0.45);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .videopress-playlist-editor__manage-item-thumb {
+ position: relative;
+ flex-shrink: 0;
+ inline-size: 54px;
+ aspect-ratio: 16 / 9;
+ border-radius: 3px;
+ overflow: hidden;
+ background: rgba(0, 0, 0, 0.08);
+
+ img {
+ position: absolute;
+ inset: 0;
+ inline-size: 100%;
+ block-size: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .videopress-playlist-editor__manage-item-text {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .videopress-playlist-editor__manage-item-title {
+ font-size: 12px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .videopress-playlist-editor__manage-item-meta {
+ font-size: 10.5px;
+ color: rgba(0, 0, 0, 0.55);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .videopress-playlist-editor__manage-item-remove {
+ flex-shrink: 0;
+ }
+}
+
+.videopress-playlist-editor__reorder-help {
+ margin: 9px 0 0;
+ font-size: 11.5px;
+ color: rgba(0, 0, 0, 0.55);
+}
+
+.videopress-playlist-editor__layout-picker {
+ display: flex;
+ gap: 6px;
+ margin-block-end: 14px;
+
+ .components-button {
+ flex: 1;
+ justify-content: center;
+ }
+}
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/index.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/index.ts
new file mode 100644
index 000000000000..8b13d484467c
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/index.ts
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+/**
+ * Internal dependencies
+ */
+// Overrides Webpack's publicPath before any lazy chunk loads on wpcom.
+import '../../set-webpack-public-path';
+import { VideoPressIcon as icon } from '../video/components/icons';
+import metadata from './block.json';
+import Edit from './edit';
+/**
+ * Types
+ */
+import type { PlaylistBlockAttributes } from './types';
+
+export const { name, title, description, attributes, category } = metadata;
+
+registerBlockType< PlaylistBlockAttributes >( name, {
+ edit: Edit,
+ save: () => null,
+ category,
+ title,
+ icon,
+ attributes,
+} );
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx
new file mode 100644
index 000000000000..05662291618d
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx
@@ -0,0 +1,509 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { fetchVideoItem } from '../../../../lib/fetch-video-item';
+import Edit from '../edit';
+import type { PlaylistBlockAttributes } from '../types';
+import type { BlockEditProps } from '@wordpress/blocks';
+
+// What the mocked media modal "returns" when the library button is clicked.
+let mockLibrarySelection: unknown = [];
+
+jest.mock( '../../../../lib/fetch-video-item', () => ( {
+ fetchVideoItem: jest.fn( () => Promise.resolve( { title: 'Fetched title' } ) ),
+} ) );
+
+const mockFetchVideoItem = fetchVideoItem as jest.Mock;
+
+jest.mock( '@wordpress/block-editor', () => ( {
+ useBlockProps: ( props: Record< string, unknown > = {} ) => props,
+ InspectorControls: ( { children }: { children: React.ReactNode } ) => (
+ { children }
+ ),
+ MediaUploadCheck: ( { children }: { children: React.ReactNode } ) => <>{ children }>,
+ MediaUpload: ( {
+ onSelect,
+ render: renderProp,
+ }: {
+ onSelect: ( selection: unknown ) => void;
+ render: ( props: { open: () => void } ) => React.ReactNode;
+ } ) => <>{ renderProp( { open: () => onSelect( mockLibrarySelection ) } ) }>,
+} ) );
+
+/**
+ * Render the Edit component with the given attributes.
+ *
+ * @param attributes - Partial block attributes.
+ * @return The setAttributes mock.
+ */
+function renderEdit( attributes: Partial< PlaylistBlockAttributes > = {} ) {
+ const setAttributes = jest.fn();
+ // The component only consumes attributes/setAttributes; the remaining
+ // BlockEditProps fields are editor-runtime plumbing it never touches.
+ const props = {
+ attributes: {
+ videos: [],
+ autoAdvance: true,
+ loop: false,
+ layout: 'rail',
+ darkSurface: false,
+ showThumbnail: true,
+ showTitle: true,
+ showResolution: true,
+ showDuration: true,
+ showPosition: false,
+ showTotalRuntime: true,
+ ...attributes,
+ },
+ setAttributes,
+ } as unknown as BlockEditProps< PlaylistBlockAttributes >;
+ render( );
+ return { setAttributes };
+}
+
+/**
+ * Scope queries to the settings sidebar.
+ *
+ * @return Queries bound to the InspectorControls container.
+ */
+function sidebar() {
+ return within( screen.getByTestId( 'inspector-controls' ) );
+}
+
+describe( 'PlaylistBlockEdit', () => {
+ it( 'shows the placeholder and adds a video by GUID with its fetched metadata', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit();
+
+ expect( screen.getByText( 'Build a video playlist' ) ).toBeInTheDocument();
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'abcd1234' );
+ await user.click( sidebar().getByText( 'Add' ) );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [ { guid: 'abcd1234', title: 'Fetched title' } ],
+ } )
+ );
+ expect( mockFetchVideoItem ).toHaveBeenCalledWith(
+ expect.objectContaining( { guid: 'abcd1234' } )
+ );
+ } );
+
+ it( 'accepts a VideoPress URL and stores duration, resolution, and poster', async () => {
+ const user = userEvent.setup();
+ mockFetchVideoItem.mockResolvedValueOnce( {
+ title: 'Kiln loading',
+ duration: 724000,
+ height: 1080,
+ poster: 'https://videos.files.wordpress.com/efgh5678/poster.jpg',
+ } );
+ const { setAttributes } = renderEdit();
+
+ await user.type(
+ sidebar().getByPlaceholderText( 'Paste a video URL' ),
+ 'https://videopress.com/v/efgh5678'
+ );
+ await user.keyboard( '{Enter}' );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [
+ {
+ guid: 'efgh5678',
+ title: 'Kiln loading',
+ durationMs: 724000,
+ height: 1080,
+ poster: 'https://videos.files.wordpress.com/efgh5678/poster.jpg',
+ },
+ ],
+ } )
+ );
+ } );
+
+ it( 'still adds the video when the metadata fetch fails', async () => {
+ const user = userEvent.setup();
+ mockFetchVideoItem.mockRejectedValueOnce( new Error( 'not reachable' ) );
+ const { setAttributes } = renderEdit();
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'abcd1234' );
+ await user.click( sidebar().getByText( 'Add' ) );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( { videos: [ { guid: 'abcd1234' } ] } )
+ );
+ } );
+
+ it( 'rejects unrecognized input with an error message', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit();
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'not a video' );
+ await user.click( sidebar().getByText( 'Add' ) );
+
+ expect( setAttributes ).not.toHaveBeenCalled();
+ // The Notice also announces via an a11y live region, so match all.
+ expect(
+ screen.getAllByText( 'No video found at that link. Paste a VideoPress video URL or GUID.' )
+ .length
+ ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'warns about duplicates and only adds again on Add anyway', async () => {
+ const user = userEvent.setup();
+ // Stored title matches the fetch mock so the background refresh no-ops.
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234', title: 'Fetched title' } ],
+ } );
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'abcd1234' );
+ await user.click( sidebar().getByText( 'Add' ) );
+
+ expect( setAttributes ).not.toHaveBeenCalled();
+ expect(
+ screen.getAllByText( '“Fetched title” is already in this playlist' ).length
+ ).toBeGreaterThan( 0 );
+
+ await user.click( sidebar().getByText( 'Add anyway' ) );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [
+ { guid: 'abcd1234', title: 'Fetched title' },
+ { guid: 'abcd1234', title: 'Fetched title' },
+ ],
+ } )
+ );
+ } );
+
+ it( 'dismisses the duplicate warning on Cancel', async () => {
+ const user = userEvent.setup();
+ // Stored title matches the fetch mock so the background refresh no-ops.
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234', title: 'Fetched title' } ],
+ } );
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'abcd1234' );
+ await user.click( sidebar().getByText( 'Add' ) );
+ await user.click( sidebar().getByText( 'Cancel' ) );
+
+ expect( setAttributes ).not.toHaveBeenCalled();
+ expect( sidebar().queryByText( 'Add anyway' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'previews the first video and shows entry metadata', () => {
+ renderEdit( {
+ videos: [
+ { guid: 'abcd1234', title: 'First', durationMs: 724000, height: 1080 },
+ { guid: 'efgh5678' },
+ ],
+ } );
+
+ const preview = screen.getByTitle( 'VideoPress Playlist Player' );
+ expect( preview ).toHaveAttribute(
+ 'src',
+ expect.stringContaining( 'videopress.com/embed/abcd1234' )
+ );
+
+ // Titles list in both the canvas preview and the sidebar manage list.
+ expect( screen.getAllByText( 'First' ) ).toHaveLength( 2 );
+ expect( screen.getAllByText( 'efgh5678' ) ).toHaveLength( 2 );
+ // Sidebar meta line and canvas meta/badges show resolution and duration.
+ expect( screen.getAllByText( '1080p · 12:04' ).length ).toBeGreaterThan( 0 );
+ expect( screen.getAllByText( '1080p' ).length ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'refreshes stored metadata that differs from the video data', async () => {
+ mockFetchVideoItem.mockResolvedValue( {
+ title: 'Fetched title',
+ duration: 400000,
+ height: 720,
+ } );
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234', title: 'Stale stored title' } ],
+ } );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [ { guid: 'abcd1234', title: 'Fetched title', durationMs: 400000, height: 720 } ],
+ } )
+ );
+ mockFetchVideoItem.mockResolvedValue( { title: 'Fetched title' } );
+ } );
+
+ it( 'removes an item from the playlist', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ],
+ } );
+
+ await user.click( screen.getAllByLabelText( 'Remove from playlist' )[ 0 ] );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { videos: [ { guid: 'efgh5678' } ] } );
+ } );
+
+ it( 'reorders videos with drag and drop', () => {
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' }, { guid: 'ijkl9012' } ],
+ } );
+
+ const items = sidebar().getAllByRole( 'option' );
+
+ fireEvent.dragStart( items[ 0 ] );
+ fireEvent.dragOver( items[ 2 ] );
+ fireEvent.drop( items[ 2 ] );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [ { guid: 'efgh5678' }, { guid: 'ijkl9012' }, { guid: 'abcd1234' } ],
+ } );
+ } );
+
+ it( 'reorders videos with the keyboard', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit( {
+ videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ],
+ } );
+
+ // Focus the first playlist option, then move it down with the arrow key.
+ await user.click( sidebar().getAllByRole( 'option' )[ 0 ] );
+ await user.keyboard( '{ArrowDown}' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [ { guid: 'efgh5678' }, { guid: 'abcd1234' } ],
+ } );
+ } );
+
+ it( 'does not reorder from the edges or while filtering', async () => {
+ const user = userEvent.setup();
+ // Titles match the fetch mock so the background refresh no-ops.
+ const videos = Array.from( { length: 9 }, ( _, i ) => ( {
+ guid: `guid000${ i }`,
+ title: 'Fetched title',
+ } ) );
+ const { setAttributes } = renderEdit( { videos } );
+
+ // ArrowUp on the first item is a no-op.
+ await user.click( sidebar().getAllByRole( 'option' )[ 0 ] );
+ await user.keyboard( '{ArrowUp}' );
+ expect( setAttributes ).not.toHaveBeenCalled();
+
+ // While filtering (the haystack includes the GUID), items are not
+ // draggable and arrows do nothing.
+ await user.type( sidebar().getByPlaceholderText( 'Filter 9 videos' ), 'guid0003' );
+ const filtered = sidebar().getAllByRole( 'option' );
+ expect( filtered ).toHaveLength( 1 );
+ expect( filtered[ 0 ] ).toHaveAttribute( 'draggable', 'false' );
+ await user.click( filtered[ 0 ] );
+ await user.keyboard( '{ArrowDown}' );
+ expect( setAttributes ).not.toHaveBeenCalled();
+ } );
+
+ it( 'clears the drop target when the drag leaves an item', () => {
+ renderEdit( { videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ] } );
+
+ const items = sidebar().getAllByRole( 'option' );
+
+ fireEvent.dragStart( items[ 0 ] );
+ fireEvent.dragOver( items[ 1 ] );
+ expect( items[ 1 ] ).toHaveClass( 'is-drop-target' );
+
+ fireEvent.dragLeave( items[ 1 ] );
+ expect( items[ 1 ] ).not.toHaveClass( 'is-drop-target' );
+
+ // Dropping on the dragged item itself is a no-op reorder.
+ fireEvent.drop( items[ 0 ] );
+ expect( items[ 0 ] ).not.toHaveClass( 'is-dragging' );
+ } );
+
+ it( 'shows the skeleton row while metadata is being read', async () => {
+ const user = userEvent.setup();
+ let resolveFetch: ( value: unknown ) => void;
+ mockFetchVideoItem.mockImplementationOnce(
+ () =>
+ new Promise( resolve => {
+ resolveFetch = resolve;
+ } )
+ );
+ renderEdit();
+
+ await user.type( sidebar().getByPlaceholderText( 'Paste a video URL' ), 'abcd1234' );
+ await user.click( sidebar().getByText( 'Add' ) );
+
+ expect( sidebar().getByText( 'Reading metadata…' ) ).toBeInTheDocument();
+
+ resolveFetch( { title: 'Fetched title' } );
+ await waitFor( () =>
+ expect( sidebar().queryByText( 'Reading metadata…' ) ).not.toBeInTheDocument()
+ );
+ } );
+
+ it( 'marks the hovered item as the drop target while dragging', () => {
+ renderEdit( { videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ] } );
+
+ const items = sidebar().getAllByRole( 'option' );
+
+ fireEvent.dragStart( items[ 0 ] );
+ fireEvent.dragOver( items[ 1 ] );
+
+ expect( items[ 0 ] ).toHaveClass( 'is-dragging' );
+ expect( items[ 1 ] ).toHaveClass( 'is-drop-target' );
+
+ fireEvent.dragEnd( items[ 0 ] );
+
+ expect( items[ 0 ] ).not.toHaveClass( 'is-dragging' );
+ expect( items[ 1 ] ).not.toHaveClass( 'is-drop-target' );
+ } );
+
+ it( 'filters long playlists without losing original positions', async () => {
+ const user = userEvent.setup();
+ const videos = Array.from( { length: 9 }, ( _, i ) => ( {
+ guid: `guid000${ i }`,
+ title: i === 8 ? 'Needle' : `Video ${ i }`,
+ } ) );
+ const { setAttributes } = renderEdit( { videos } );
+
+ await user.type( sidebar().getByPlaceholderText( 'Filter 9 videos' ), 'Needle' );
+
+ const items = sidebar().getAllByRole( 'option' );
+ expect( items ).toHaveLength( 1 );
+ // Removing the filtered item removes the right entry.
+ await user.click( within( items[ 0 ] ).getByLabelText( 'Remove from playlist' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: videos.slice( 0, 8 ),
+ } );
+ } );
+
+ it( 'switches the preview when selecting another item', async () => {
+ const user = userEvent.setup();
+ renderEdit( { videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ] } );
+
+ await user.click( screen.getByLabelText( 'Preview video 2' ) );
+
+ expect( screen.getByTitle( 'VideoPress Playlist Player' ) ).toHaveAttribute(
+ 'src',
+ expect.stringContaining( 'videopress.com/embed/efgh5678' )
+ );
+ } );
+
+ it( 'adds VideoPress videos selected from the media library', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit( { videos: [ { guid: 'abcd1234' } ] } );
+
+ mockLibrarySelection = [
+ {
+ videopress_guid: [ 'efgh5678' ],
+ title: 'Library video',
+ image: { src: 'https://example.test/thumb.jpg' },
+ },
+ { videopress_guid: 'ijkl9012', title: '' },
+ { title: 'Not a VideoPress video' },
+ ];
+
+ await user.click( sidebar().getByText( 'Media Library' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ videos: [
+ { guid: 'abcd1234' },
+ { guid: 'efgh5678', title: 'Library video', poster: 'https://example.test/thumb.jpg' },
+ { guid: 'ijkl9012' },
+ ],
+ } );
+ } );
+
+ it( 'shows an error when no library selection is a VideoPress video', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit();
+
+ mockLibrarySelection = [ { title: 'Plain video attachment' } ];
+
+ await user.click( sidebar().getByText( 'Media Library' ) );
+
+ expect( setAttributes ).not.toHaveBeenCalled();
+ // The Notice also announces via an a11y live region, so match all.
+ expect(
+ screen.getAllByText(
+ 'None of the selected items are VideoPress videos. Choose videos hosted on VideoPress.'
+ ).length
+ ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'keeps all management controls in the settings sidebar; the canvas is preview-only', () => {
+ renderEdit( { videos: [ { guid: 'abcd1234' }, { guid: 'efgh5678' } ] } );
+
+ // Add, drag-to-sort, and delete all live in the sidebar.
+ expect( sidebar().getByPlaceholderText( 'Paste a video URL' ) ).toBeInTheDocument();
+ expect( sidebar().getByText( 'Add' ) ).toBeInTheDocument();
+ expect( sidebar().getByText( 'Media Library' ) ).toBeInTheDocument();
+ expect( sidebar().getAllByLabelText( 'Remove from playlist' ) ).toHaveLength( 2 );
+ for ( const item of sidebar().getAllByRole( 'option' ) ) {
+ expect( item ).toHaveAttribute( 'draggable', 'true' );
+ }
+
+ // The canvas only previews: item selection, no management controls.
+ expect( screen.getAllByLabelText( 'Remove from playlist' ) ).toHaveLength( 2 );
+ expect( screen.getByLabelText( 'Preview video 2' ) ).toBeInTheDocument();
+ } );
+
+ it( 'offers URL input and Media Library in the empty-state placeholder', () => {
+ renderEdit();
+
+ expect( screen.getByText( 'Build a video playlist' ) ).toBeInTheDocument();
+
+ // Both the placeholder and the sidebar offer the add controls.
+ expect( screen.getAllByPlaceholderText( 'Paste a video URL' ) ).toHaveLength( 2 );
+ expect( screen.getAllByText( 'Media Library' ) ).toHaveLength( 2 );
+ } );
+
+ it( 'switches layout from the layout picker', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit( { videos: [ { guid: 'abcd1234' } ] } );
+
+ await user.click( sidebar().getByText( 'Grid' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { layout: 'grid' } );
+
+ await user.click( sidebar().getByText( 'Strip' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { layout: 'strip' } );
+ } );
+
+ it( 'toggles playback and display settings', async () => {
+ const user = userEvent.setup();
+ const { setAttributes } = renderEdit( { videos: [ { guid: 'abcd1234' } ] } );
+
+ await user.click( sidebar().getByLabelText( 'Autoplay next' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { autoAdvance: false } );
+
+ await user.click( sidebar().getByLabelText( 'Loop playlist' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { loop: true } );
+
+ await user.click( sidebar().getByLabelText( 'Dark player surface' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { darkSurface: true } );
+
+ await user.click( sidebar().getByLabelText( 'Thumbnail' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { showThumbnail: false } );
+
+ await user.click( sidebar().getByLabelText( 'Position number' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { showPosition: true } );
+
+ await user.click( sidebar().getByLabelText( 'Total runtime in header' ) );
+ expect( setAttributes ).toHaveBeenCalledWith( { showTotalRuntime: false } );
+ } );
+
+ it( 'reflects layout and display attributes on the block wrapper', () => {
+ renderEdit( {
+ videos: [ { guid: 'abcd1234' } ],
+ layout: 'grid',
+ darkSurface: true,
+ showThumbnail: false,
+ showPosition: true,
+ } );
+
+ const preview = screen.getByTitle( 'VideoPress Playlist Player' );
+ // The wrapper is a plain div carrying only CSS classes, so there is no
+ // accessible query for it; walk up from the player instead.
+ // eslint-disable-next-line testing-library/no-node-access
+ const wrapper = preview.closest( '.videopress-playlist--grid' );
+ expect( wrapper ).not.toBeNull();
+ expect( wrapper ).toHaveClass( 'is-dark' );
+ expect( wrapper ).toHaveClass( 'hide-thumbnails' );
+ expect( wrapper ).toHaveClass( 'show-position' );
+ } );
+} );
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/index.test.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/index.test.ts
new file mode 100644
index 000000000000..10ac120c4299
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/index.test.ts
@@ -0,0 +1,26 @@
+const mockRegisterBlockType = jest.fn();
+
+jest.mock( '@wordpress/blocks', () => ( {
+ registerBlockType: mockRegisterBlockType,
+} ) );
+
+// The edit component pulls in the real block-editor package, which expects a
+// full editor runtime; the registration test only needs stand-ins.
+jest.mock( '@wordpress/block-editor', () => ( {
+ useBlockProps: ( props: Record< string, unknown > = {} ) => props,
+ InspectorControls: () => null,
+} ) );
+
+describe( 'playlist block registration', () => {
+ it( 'registers videopress/playlist with an edit implementation', async () => {
+ await import( '../index' );
+
+ expect( mockRegisterBlockType ).toHaveBeenCalledTimes( 1 );
+
+ const [ name, settings ] = mockRegisterBlockType.mock.calls[ 0 ];
+ expect( name ).toBe( 'videopress/playlist' );
+ expect( settings.edit ).toEqual( expect.any( Function ) );
+ expect( settings.save() ).toBeNull();
+ expect( settings.attributes ).toHaveProperty( 'videos' );
+ } );
+} );
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/view.test.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/view.test.ts
new file mode 100644
index 000000000000..0bf26f586633
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/view.test.ts
@@ -0,0 +1,326 @@
+import {
+ formatDuration,
+ initAllPlaylists,
+ initPlaylist,
+ qualityLabel,
+ refreshPlaylistMetadata,
+} from '../view';
+
+const PLAYER_SELECTOR = '.videopress-playlist__player';
+const ITEM_SELECTOR = '.videopress-playlist__item';
+
+/**
+ * Build a playlist block DOM matching the server-rendered markup.
+ *
+ * @param guids - Video GUIDs, first one loaded in the player.
+ * @param autoAdvance - Value for data-auto-advance.
+ * @param loop - Value for data-loop.
+ * @return The block wrapper element, attached to the document.
+ */
+function buildPlaylist( guids: string[], autoAdvance = '1', loop = '0' ): HTMLElement {
+ const block = document.createElement( 'figure' );
+ block.className = 'wp-block-videopress-playlist';
+ block.dataset.autoAdvance = autoAdvance;
+ block.dataset.loop = loop;
+
+ const items = guids
+ .map(
+ ( guid, index ) =>
+ ``
+ )
+ .join( '' );
+
+ block.innerHTML =
+ `` +
+ ``;
+
+ document.body.appendChild( block );
+ return block;
+}
+
+/**
+ * Dispatch a player message event like the VideoPress embed does.
+ *
+ * @param guid - GUID the message reports on.
+ * @param origin - Message origin.
+ * @param event - Player event name.
+ */
+function postPlayerMessage(
+ guid: string,
+ origin = 'https://videopress.com',
+ event = 'videopress_ended'
+) {
+ window.dispatchEvent( new MessageEvent( 'message', { data: { event, id: guid }, origin } ) );
+}
+
+describe( 'playlist view script', () => {
+ beforeAll( () => {
+ // The jsdom test environment ships no fetch implementation to spy on.
+ Object.defineProperty( global, 'fetch', { writable: true, value: jest.fn() } );
+ } );
+
+ beforeEach( () => {
+ // Default: metadata lookups fail, so server-rendered labels stay put.
+ ( global.fetch as jest.Mock ).mockReset().mockResolvedValue( { ok: false } as Response );
+ } );
+
+ afterEach( () => {
+ document.body.innerHTML = '';
+ } );
+
+ it( 'swaps the player to the clicked item and marks it current', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ const items = block.querySelectorAll< HTMLButtonElement >( ITEM_SELECTOR );
+
+ items[ 1 ].click();
+
+ expect( player.src ).toBe( 'https://videopress.com/embed/bbbb2222?autoPlay=1' );
+ expect( player.dataset.guid ).toBe( 'bbbb2222' );
+ expect( items[ 1 ] ).toHaveClass( 'is-current' );
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-current', 'true' );
+ expect( items[ 0 ] ).not.toHaveClass( 'is-current' );
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-current', 'false' );
+ } );
+
+ it( 'advances to the next video when the current one ends', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.src ).toBe( 'https://videopress.com/embed/bbbb2222?autoPlay=1' );
+ } );
+
+ it( 'ignores ended messages from untrusted origins', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111', 'https://evil.example' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.dataset.guid ).toBe( 'aaaa1111' );
+ } );
+
+ it( 'ignores ended messages for videos other than the current one', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222', 'cccc3333' ] );
+ initPlaylist( block );
+
+ postPlayerMessage( 'bbbb2222' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.dataset.guid ).toBe( 'aaaa1111' );
+ } );
+
+ it( 'ignores non-ended player events', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111', 'https://videopress.com', 'videopress_playing' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.dataset.guid ).toBe( 'aaaa1111' );
+ } );
+
+ it( 'stops at the last video when looping is off', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111' );
+ postPlayerMessage( 'bbbb2222' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.dataset.guid ).toBe( 'bbbb2222' );
+ } );
+
+ it( 'returns to the first video when looping is on', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ], '1', '1' );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111' );
+ postPlayerMessage( 'bbbb2222' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.src ).toBe( 'https://videopress.com/embed/aaaa1111?autoPlay=1' );
+ } );
+
+ it( 'does not auto-advance when auto-advance is off, but clicks still work', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ], '0' );
+ initPlaylist( block );
+
+ postPlayerMessage( 'aaaa1111' );
+
+ const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR );
+ expect( player.dataset.guid ).toBe( 'aaaa1111' );
+
+ block.querySelectorAll< HTMLButtonElement >( ITEM_SELECTOR )[ 1 ].click();
+ expect( player.dataset.guid ).toBe( 'bbbb2222' );
+ } );
+
+ it( 'keeps two playlists on the same page independent', () => {
+ const first = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ const second = buildPlaylist( [ 'dddd4444', 'eeee5555' ] );
+ initAllPlaylists();
+
+ postPlayerMessage( 'aaaa1111' );
+
+ expect( first.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ).dataset.guid ).toBe(
+ 'bbbb2222'
+ );
+ expect( second.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ).dataset.guid ).toBe(
+ 'dddd4444'
+ );
+ } );
+
+ it( 'refreshes titles, durations, badges, posters, and the total runtime', async () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ ( global.fetch as jest.Mock ).mockImplementation( ( url: string ) =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ url.includes( 'aaaa1111' )
+ ? {
+ title: 'Renamed on VideoPress',
+ duration: 83000,
+ height: 1080,
+ poster: 'https://example.test/poster-a.jpg',
+ }
+ : { title: 'Second video', duration: 3723000, height: 2160 }
+ ),
+ } )
+ );
+
+ await refreshPlaylistMetadata( block );
+
+ const items = block.querySelectorAll< HTMLButtonElement >( ITEM_SELECTOR );
+ expect( items[ 0 ].querySelector( '.videopress-playlist__item-title' ) ).toHaveTextContent(
+ 'Renamed on VideoPress'
+ );
+ expect( items[ 0 ].querySelector( '.videopress-playlist__item-duration' ) ).toHaveTextContent(
+ '1:23'
+ );
+ expect(
+ items[ 0 ].querySelector( '.videopress-playlist__item-thumb-duration' )
+ ).toHaveTextContent( '1:23' );
+ expect( items[ 0 ].querySelector( '.videopress-playlist__item-badge' ) ).toHaveTextContent(
+ '1080p'
+ );
+ expect( items[ 0 ].querySelector( '.videopress-playlist__item-thumb img' ) ).toHaveAttribute(
+ 'src',
+ 'https://example.test/poster-a.jpg'
+ );
+ expect( items[ 1 ].querySelector( '.videopress-playlist__item-duration' ) ).toHaveTextContent(
+ '1:02:03'
+ );
+ expect( items[ 1 ].querySelector( '.videopress-playlist__item-badge' ) ).toHaveTextContent(
+ '4K'
+ );
+ // 83000 + 3723000 ms ≈ 1 hr 3 min total runtime.
+ expect( block.querySelector( '.videopress-playlist__runtime' ) ).toHaveTextContent(
+ '1 hr 3 min'
+ );
+ } );
+
+ it( 'reuses the poster image and tolerates missing header/guid on refresh', async () => {
+ const block = buildPlaylist( [ 'aaaa1111' ] );
+ // No runtime element and an item without a GUID must not break the refresh.
+ block.querySelector( '.videopress-playlist__runtime' ).remove();
+ block
+ .querySelector< HTMLButtonElement >( ITEM_SELECTOR )
+ .insertAdjacentHTML(
+ 'beforebegin',
+ ''
+ );
+
+ ( global.fetch as jest.Mock ).mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { duration: 5000, poster: 'https://example.test/p.jpg' } ),
+ } );
+
+ await refreshPlaylistMetadata( block );
+ await refreshPlaylistMetadata( block );
+
+ // The second refresh reuses the created img instead of adding another.
+ expect( block.querySelectorAll( '.videopress-playlist__item-thumb img' ) ).toHaveLength( 1 );
+ // Only the GUID-carrying item was fetched, once per refresh.
+ expect( global.fetch ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'keeps server-rendered labels when the metadata lookup fails', async () => {
+ const block = buildPlaylist( [ 'aaaa1111' ] );
+
+ await refreshPlaylistMetadata( block );
+
+ const item = block.querySelector< HTMLButtonElement >( ITEM_SELECTOR );
+ expect( item.querySelector( '.videopress-playlist__item-title' ) ).toHaveTextContent(
+ 'Video 1'
+ );
+ expect( item.querySelector( '.videopress-playlist__item-badge' ) ).toHaveTextContent( '' );
+ expect( item.querySelector( '.videopress-playlist__item-duration' ) ).toHaveTextContent( '' );
+ } );
+
+ it( 'requests metadata when a playlist initializes', () => {
+ const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] );
+ initPlaylist( block );
+
+ expect( global.fetch ).toHaveBeenCalledWith(
+ 'https://public-api.wordpress.com/rest/v1.1/videos/aaaa1111'
+ );
+ expect( global.fetch ).toHaveBeenCalledWith(
+ 'https://public-api.wordpress.com/rest/v1.1/videos/bbbb2222'
+ );
+ } );
+
+ it( 'bails on blocks without a player or items', () => {
+ const empty = document.createElement( 'figure' );
+ empty.className = 'wp-block-videopress-playlist';
+ document.body.appendChild( empty );
+
+ expect( () => initAllPlaylists() ).not.toThrow();
+ } );
+} );
+
+describe( 'formatDuration', () => {
+ it.each( [
+ [ 83000, '1:23' ],
+ [ 3723000, '1:02:03' ],
+ [ 500, '0:01' ],
+ [ 59499, '0:59' ],
+ [ 3600000, '1:00:00' ],
+ ] )( 'formats %d ms as %s', ( ms, expected ) => {
+ expect( formatDuration( ms ) ).toBe( expected );
+ } );
+
+ it.each( [ [ 0 ], [ -5 ], [ NaN ], [ Infinity ] ] )( 'returns an empty string for %p', ms => {
+ expect( formatDuration( ms ) ).toBe( '' );
+ } );
+} );
+
+describe( 'qualityLabel', () => {
+ it.each( [
+ [ 2160, '4K' ],
+ [ 4320, '4K' ],
+ [ 1080, '1080p' ],
+ [ 720, '720p' ],
+ [ 480, '480p' ],
+ ] )( 'labels height %d as %s', ( height, expected ) => {
+ expect( qualityLabel( height ) ).toBe( expected );
+ } );
+
+ it.each( [ [ 0 ], [ -1 ], [ NaN ] ] )( 'returns an empty string for %p', height => {
+ expect( qualityLabel( height ) ).toBe( '' );
+ } );
+} );
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/types.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/types.ts
new file mode 100644
index 000000000000..a6c70522190d
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/types.ts
@@ -0,0 +1,25 @@
+import type { VideoGUID } from '../video/types';
+
+export type PlaylistVideo = {
+ guid: VideoGUID;
+ title?: string;
+ durationMs?: number;
+ height?: number;
+ poster?: string;
+};
+
+export type PlaylistLayout = 'rail' | 'grid' | 'strip';
+
+export type PlaylistBlockAttributes = {
+ videos: PlaylistVideo[];
+ autoAdvance: boolean;
+ loop: boolean;
+ layout: PlaylistLayout;
+ darkSurface: boolean;
+ showThumbnail: boolean;
+ showTitle: boolean;
+ showResolution: boolean;
+ showDuration: boolean;
+ showPosition: boolean;
+ showTotalRuntime: boolean;
+};
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/utils.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/utils.ts
new file mode 100644
index 000000000000..a5890663da9e
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/utils.ts
@@ -0,0 +1,82 @@
+/**
+ * Formatting helpers shared by the playlist editor and view script.
+ */
+
+/**
+ * Format a millisecond duration as m:ss or h:mm:ss.
+ *
+ * @param durationMs - Duration in milliseconds.
+ * @return Formatted duration, or an empty string for unusable input.
+ */
+export function formatDuration( durationMs: number ): string {
+ if ( ! Number.isFinite( durationMs ) || durationMs <= 0 ) {
+ return '';
+ }
+
+ const totalSeconds = Math.round( durationMs / 1000 );
+ const hours = Math.floor( totalSeconds / 3600 );
+ const minutes = Math.floor( ( totalSeconds % 3600 ) / 60 );
+ const seconds = totalSeconds % 60;
+
+ const paddedSeconds = String( seconds ).padStart( 2, '0' );
+
+ if ( hours > 0 ) {
+ return `${ hours }:${ String( minutes ).padStart( 2, '0' ) }:${ paddedSeconds }`;
+ }
+
+ return `${ minutes }:${ paddedSeconds }`;
+}
+
+/**
+ * Format a millisecond duration as a long runtime, e.g. "1 hr 13 min".
+ *
+ * @param durationMs - Duration in milliseconds.
+ * @return Formatted runtime, or an empty string for unusable input.
+ */
+export function formatRuntimeLong( durationMs: number ): string {
+ if ( ! Number.isFinite( durationMs ) || durationMs <= 0 ) {
+ return '';
+ }
+
+ const totalMinutes = Math.max( 1, Math.round( durationMs / 60000 ) );
+ const hours = Math.floor( totalMinutes / 60 );
+ const minutes = totalMinutes % 60;
+
+ if ( hours > 0 ) {
+ return minutes > 0 ? `${ hours } hr ${ minutes } min` : `${ hours } hr`;
+ }
+
+ return `${ minutes } min`;
+}
+
+/**
+ * Map a video height to a quality/resolution badge label.
+ *
+ * @param height - Video height in pixels.
+ * @return Badge label, or an empty string for unusable input.
+ */
+export function qualityLabel( height: number ): string {
+ if ( ! Number.isFinite( height ) || height <= 0 ) {
+ return '';
+ }
+
+ if ( height >= 2160 ) {
+ return '4K';
+ }
+
+ return `${ height }p`;
+}
+
+/**
+ * Sum the known durations of a list of playlist videos.
+ *
+ * @param videos - Playlist entries.
+ * @return Total known duration in milliseconds (0 when nothing is known).
+ */
+export function totalDurationMs( videos: Array< { durationMs?: number } > ): number {
+ return videos.reduce(
+ ( sum, video ) =>
+ Number.isFinite( video.durationMs ) && video.durationMs > 0 ? sum + video.durationMs : sum,
+ 0
+ );
+}
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.scss b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.scss
new file mode 100644
index 000000000000..58c158719291
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.scss
@@ -0,0 +1,312 @@
+.wp-block-videopress-playlist {
+ --vpp-border: rgba(0, 0, 0, 0.12);
+ --vpp-border-soft: rgba(0, 0, 0, 0.06);
+ --vpp-muted: rgba(0, 0, 0, 0.55);
+ --vpp-current-bg: rgba(0, 0, 0, 0.05);
+ --vpp-accent: var(--wp--preset--color--primary, #2a78d6);
+
+ &.is-dark {
+ --vpp-border: rgba(255, 255, 255, 0.16);
+ --vpp-border-soft: rgba(255, 255, 255, 0.08);
+ --vpp-muted: rgba(255, 255, 255, 0.6);
+ --vpp-current-bg: rgba(255, 255, 255, 0.08);
+
+ background: #131315;
+ color: #f2f1ee;
+ padding: 24px;
+ border-radius: 8px;
+ }
+
+ .videopress-playlist__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: flex-end;
+ gap: 6px;
+ margin-block-end: 12px;
+ font-size: 0.8em;
+ color: var(--vpp-muted);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .videopress-playlist__runtime::before {
+ content: "· ";
+ }
+
+ &.hide-runtime .videopress-playlist__runtime {
+ display: none;
+ }
+
+ .videopress-playlist__player-wrapper {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ background: #000;
+ border-radius: 6px;
+ overflow: hidden;
+ }
+
+ .videopress-playlist__player {
+ position: absolute;
+ inset: 0;
+ inline-size: 100%;
+ block-size: 100%;
+ border: 0;
+ }
+
+ .videopress-playlist__items {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .videopress-playlist__item {
+ display: flex;
+ gap: 10px;
+ inline-size: 100%;
+ padding: 10px 12px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: start;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ background: var(--vpp-current-bg);
+ }
+ }
+
+ .videopress-playlist__item-thumb {
+ position: relative;
+ flex-shrink: 0;
+ inline-size: 76px;
+ aspect-ratio: 16 / 9;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--vpp-border-soft);
+
+ img {
+ position: absolute;
+ inset: 0;
+ inline-size: 100%;
+ block-size: 100%;
+ object-fit: cover;
+ max-inline-size: 100%;
+ }
+ }
+
+ .videopress-playlist__item-index {
+ display: none;
+ position: absolute;
+ inset-inline-start: 4px;
+ inset-block-start: 4px;
+ padding: 1px 4px;
+ border-radius: 2px;
+ background: rgba(255, 255, 255, 0.85);
+ color: #1e1e1e;
+ font-size: 0.7em;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &.show-position .videopress-playlist__item-index {
+ display: inline-block;
+ }
+
+ .videopress-playlist__item-thumb-duration {
+ position: absolute;
+ inset-inline-end: 4px;
+ inset-block-end: 4px;
+ padding: 1px 4px;
+ border-radius: 2px;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ font-size: 0.7em;
+ font-variant-numeric: tabular-nums;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ .videopress-playlist__item-text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-inline-size: 0;
+ }
+
+ .videopress-playlist__item-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.9em;
+ }
+
+ .videopress-playlist__item-meta {
+ display: flex;
+ gap: 4px;
+ font-size: 0.75em;
+ color: var(--vpp-muted);
+ font-variant-numeric: tabular-nums;
+
+ .videopress-playlist__item-badge + .videopress-playlist__item-duration:not(:empty)::before {
+ content: "· ";
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ .videopress-playlist__item.is-current {
+ background: var(--vpp-current-bg);
+
+ .videopress-playlist__item-title {
+ font-weight: 600;
+ }
+
+ .videopress-playlist__item-thumb {
+ outline: 2px solid var(--vpp-accent);
+ outline-offset: -2px;
+ }
+ }
+
+ // Display toggles.
+ &.hide-thumbnails .videopress-playlist__item-thumb {
+ display: none;
+ }
+
+ &.hide-titles .videopress-playlist__item-title {
+ display: none;
+ }
+
+ &.hide-resolution .videopress-playlist__item-badge {
+ display: none;
+ }
+
+ &.hide-duration .videopress-playlist__item-duration,
+ &.hide-duration .videopress-playlist__item-thumb-duration {
+ display: none;
+ }
+
+ // Layout: side rail (default) — player left, vertical list right.
+ &.videopress-playlist--rail {
+
+ .videopress-playlist__body {
+ display: flex;
+ gap: 18px;
+ align-items: flex-start;
+ }
+
+ .videopress-playlist__player-wrapper {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+ }
+
+ .videopress-playlist__items {
+ flex-shrink: 0;
+ inline-size: min(296px, 38%);
+ max-block-size: 420px;
+ overflow-y: auto;
+ border: 1px solid var(--vpp-border);
+ border-radius: 6px;
+ }
+
+ .videopress-playlist__item {
+ border-block-start: 1px solid var(--vpp-border-soft);
+ }
+
+ li:first-child .videopress-playlist__item {
+ border-block-start: 0;
+ }
+
+ .videopress-playlist__item.is-current {
+ border-inline-start: 3px solid var(--vpp-accent);
+ padding-inline-start: 9px;
+
+ .videopress-playlist__item-thumb {
+ outline: none;
+ }
+ }
+ }
+
+ // Layout: grid — player on top, cards below.
+ &.videopress-playlist--grid {
+
+ .videopress-playlist__player-wrapper {
+ margin-block-end: 16px;
+ }
+
+ .videopress-playlist__items {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 14px;
+ }
+
+ .videopress-playlist__item {
+ flex-direction: column;
+ gap: 7px;
+ padding: 0;
+
+ &:hover,
+ &:focus-visible,
+ &.is-current {
+ background: transparent;
+ }
+ }
+
+ .videopress-playlist__item-thumb {
+ inline-size: 100%;
+ border-radius: 5px;
+ }
+ }
+
+ // Layout: strip — player on top, horizontal scroll strip below.
+ &.videopress-playlist--strip {
+
+ .videopress-playlist__player-wrapper {
+ margin-block-end: 16px;
+ }
+
+ .videopress-playlist__items {
+ display: flex;
+ gap: 10px;
+ overflow-x: auto;
+ padding-block-end: 4px;
+ }
+
+ li {
+ flex-shrink: 0;
+ inline-size: 176px;
+ }
+
+ .videopress-playlist__item {
+ flex-direction: column;
+ gap: 6px;
+ padding: 0;
+
+ &:hover,
+ &:focus-visible,
+ &.is-current {
+ background: transparent;
+ }
+ }
+
+ .videopress-playlist__item-thumb {
+ inline-size: 100%;
+ border-radius: 3px;
+ }
+ }
+
+ // Narrow viewports: the rail folds under the player.
+ @media (max-width: 600px) {
+
+ &.videopress-playlist--rail .videopress-playlist__body {
+ flex-direction: column;
+ }
+
+ &.videopress-playlist--rail .videopress-playlist__items {
+ inline-size: 100%;
+ }
+ }
+}
diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts
new file mode 100644
index 000000000000..f634c8796fc3
--- /dev/null
+++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts
@@ -0,0 +1,208 @@
+/**
+ * WordPress dependencies
+ */
+import domReady from '@wordpress/dom-ready';
+/**
+ * Internal dependencies
+ */
+import { isAllowedOrigin } from '../../../lib/videopress-allowed-origins';
+import { formatDuration, formatRuntimeLong, qualityLabel } from './utils';
+import './view.scss';
+
+export { formatDuration, formatRuntimeLong, qualityLabel };
+
+type PlayerMessage = {
+ event?: string;
+ id?: string;
+};
+
+type VideoMetadata = {
+ title?: string;
+ duration?: number;
+ height?: number;
+ poster?: string;
+};
+
+/**
+ * Refresh a playlist's item metadata (title, thumbnail, duration, quality)
+ * from the VideoPress API, so the list always reflects the videos' current
+ * data, and update the header's total runtime.
+ *
+ * Lookups are anonymous: private videos (or API failures) keep their
+ * server-rendered fields and simply get no duration/quality badge.
+ *
+ * @param block - The playlist block wrapper element.
+ * @return Promise resolving once every item has been processed.
+ */
+export function refreshPlaylistMetadata( block: HTMLElement ): Promise< void > {
+ const items = Array.from(
+ block.querySelectorAll< HTMLButtonElement >( '.videopress-playlist__item' )
+ );
+
+ return Promise.all(
+ items.map( async item => {
+ const guid = item.dataset.guid;
+ if ( ! guid ) {
+ return;
+ }
+
+ let metadata: VideoMetadata;
+ try {
+ const response = await fetch(
+ `https://public-api.wordpress.com/rest/v1.1/videos/${ encodeURIComponent( guid ) }`
+ );
+ if ( ! response.ok ) {
+ return;
+ }
+ metadata = await response.json();
+ } catch {
+ return;
+ }
+
+ if ( metadata?.title ) {
+ const titleElement = item.querySelector( '.videopress-playlist__item-title' );
+ if ( titleElement ) {
+ titleElement.textContent = metadata.title;
+ }
+ }
+
+ if ( Number.isFinite( metadata?.duration ) && metadata.duration > 0 ) {
+ item.dataset.durationMs = String( metadata.duration );
+ }
+
+ const durationElement = item.querySelector( '.videopress-playlist__item-duration' );
+ if ( durationElement ) {
+ durationElement.textContent = formatDuration( metadata?.duration );
+ }
+
+ const thumbDurationElement = item.querySelector(
+ '.videopress-playlist__item-thumb-duration'
+ );
+ if ( thumbDurationElement ) {
+ thumbDurationElement.textContent = formatDuration( metadata?.duration );
+ }
+
+ const badgeElement = item.querySelector( '.videopress-playlist__item-badge' );
+ if ( badgeElement ) {
+ badgeElement.textContent = qualityLabel( metadata?.height );
+ }
+
+ const thumbElement = item.querySelector( '.videopress-playlist__item-thumb' );
+ if ( thumbElement && metadata?.poster ) {
+ let image = thumbElement.querySelector( 'img' );
+ if ( ! image ) {
+ image = document.createElement( 'img' );
+ image.alt = '';
+ image.loading = 'lazy';
+ thumbElement.prepend( image );
+ }
+ if ( image.src !== metadata.poster ) {
+ image.src = metadata.poster;
+ }
+ }
+ } )
+ ).then( () => {
+ // Recompute the header's total runtime from the freshest durations.
+ const runtimeElement = block.querySelector( '.videopress-playlist__runtime' );
+ if ( ! runtimeElement ) {
+ return;
+ }
+
+ const total = items.reduce( ( sum, item ) => {
+ const durationMs = Number( item.dataset.durationMs );
+ return Number.isFinite( durationMs ) && durationMs > 0 ? sum + durationMs : sum;
+ }, 0 );
+
+ const runtime = formatRuntimeLong( total );
+ if ( runtime ) {
+ runtimeElement.textContent = runtime;
+ }
+ } );
+}
+
+/**
+ * Wire up a single playlist block: item clicks swap the player iframe,
+ * and `videopress_ended` messages from the player advance the playlist.
+ *
+ * @param block - The playlist block wrapper element.
+ */
+export function initPlaylist( block: HTMLElement ) {
+ const player = block.querySelector< HTMLIFrameElement >( '.videopress-playlist__player' );
+ const items = Array.from(
+ block.querySelectorAll< HTMLButtonElement >( '.videopress-playlist__item' )
+ );
+
+ if ( ! player || ! items.length ) {
+ return;
+ }
+
+ // Fire-and-forget: the list stays usable with the server-rendered labels
+ // while (or if) the metadata lookups are still pending.
+ refreshPlaylistMetadata( block );
+
+ const autoAdvance = block.dataset.autoAdvance === '1';
+ const loop = block.dataset.loop === '1';
+ let currentIndex = items.findIndex( item => item.dataset.guid === player.dataset.guid );
+
+ if ( currentIndex === -1 ) {
+ currentIndex = 0;
+ }
+
+ const setCurrent = ( index: number ) => {
+ const item = items[ index ];
+ if ( ! item || ! item.dataset.src ) {
+ return;
+ }
+
+ currentIndex = index;
+ player.src = item.dataset.src;
+ player.dataset.guid = item.dataset.guid;
+
+ items.forEach( ( entry, i ) => {
+ entry.classList.toggle( 'is-current', i === index );
+ entry.setAttribute( 'aria-current', i === index ? 'true' : 'false' );
+ } );
+ };
+
+ items.forEach( ( item, index ) => {
+ item.addEventListener( 'click', () => setCurrent( index ) );
+ } );
+
+ if ( ! autoAdvance ) {
+ return;
+ }
+
+ window.addEventListener( 'message', ( event: MessageEvent< PlayerMessage > ) => {
+ if ( ! isAllowedOrigin( event.origin ) ) {
+ return;
+ }
+
+ const { event: eventName, id } = event.data || {};
+ if ( eventName !== 'videopress_ended' || ! id ) {
+ return;
+ }
+
+ // Only react to the video currently loaded in this playlist's player.
+ if ( id !== items[ currentIndex ]?.dataset.guid ) {
+ return;
+ }
+
+ const nextIndex = currentIndex + 1;
+ if ( nextIndex < items.length ) {
+ setCurrent( nextIndex );
+ } else if ( loop ) {
+ setCurrent( 0 );
+ }
+ } );
+}
+
+/**
+ * Initialize every playlist block on the page.
+ */
+export function initAllPlaylists() {
+ document
+ .querySelectorAll< HTMLElement >( '.wp-block-videopress-playlist' )
+ .forEach( initPlaylist );
+}
+
+domReady( initAllPlaylists );
diff --git a/projects/packages/videopress/tests/php/Playlist_Block_Test.php b/projects/packages/videopress/tests/php/Playlist_Block_Test.php
new file mode 100644
index 000000000000..b6709c4b5a79
--- /dev/null
+++ b/projects/packages/videopress/tests/php/Playlist_Block_Test.php
@@ -0,0 +1,314 @@
+is_registered( 'videopress/playlist' ) ) {
+ \WP_Block_Type_Registry::get_instance()->unregister( 'videopress/playlist' );
+ }
+ }
+
+ /**
+ * Write a block.json fixture for registration tests and return its path.
+ *
+ * The build output isn't present when the PHP suite runs in CI, so
+ * registration is exercised against a fixture instead.
+ *
+ * @param array $metadata Metadata to encode.
+ * @return string Path to the fixture file.
+ */
+ private function create_metadata_fixture( $metadata ) {
+ // register_block_type_from_metadata() requires the file to be named block.json.
+ $dir = get_temp_dir() . uniqid( 'playlist-block-', true );
+ mkdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ $file = $dir . '/block.json';
+ file_put_contents( $file, wp_json_encode( $metadata, JSON_UNESCAPED_SLASHES ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+
+ return $file;
+ }
+
+ /**
+ * Remove a fixture created by create_metadata_fixture().
+ *
+ * @param string $file Path returned by create_metadata_fixture().
+ */
+ private function remove_metadata_fixture( $file ) {
+ unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ rmdir( dirname( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ }
+
+ /**
+ * Render the playlist block through do_blocks() so block supports
+ * (wrapper class names) apply like on a real page.
+ *
+ * @param array $attributes Block attributes.
+ * @return string Rendered markup.
+ */
+ private function render( $attributes ) {
+ if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) {
+ register_block_type(
+ 'videopress/playlist',
+ array(
+ 'render_callback' => array( VideoPress_Initializer::class, 'render_videopress_playlist_block' ),
+ )
+ );
+ }
+
+ // Empty attributes encode to `[]`, which the block parser rejects; omit them instead.
+ $json = empty( $attributes ) ? '' : wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' ';
+ return do_blocks( '' );
+ }
+
+ /** Tests that the block registers from metadata with the render callback wired. */
+ public function test_registration_from_metadata() {
+ $fixture = $this->create_metadata_fixture(
+ array(
+ 'name' => 'videopress/playlist',
+ 'title' => 'VideoPress Playlist',
+ )
+ );
+
+ VideoPress_Initializer::register_videopress_playlist_block( $fixture );
+
+ $registry = \WP_Block_Type_Registry::get_instance();
+ $this->assertTrue( $registry->is_registered( 'videopress/playlist' ) );
+ $this->assertSame(
+ array( VideoPress_Initializer::class, 'render_videopress_playlist_block' ),
+ $registry->get_registered( 'videopress/playlist' )->render_callback
+ );
+
+ // A second call must not fatal on (or duplicate) the existing registration.
+ VideoPress_Initializer::register_videopress_playlist_block( $fixture );
+ $this->assertTrue( $registry->is_registered( 'videopress/playlist' ) );
+
+ $this->remove_metadata_fixture( $fixture );
+ }
+
+ /** Tests that registration is skipped when the metadata file is missing or unusable. */
+ public function test_registration_skipped_without_usable_metadata() {
+ $registry = \WP_Block_Type_Registry::get_instance();
+
+ VideoPress_Initializer::register_videopress_playlist_block( '/nonexistent/block.json' );
+ $this->assertFalse( $registry->is_registered( 'videopress/playlist' ) );
+
+ /*
+ * The no-argument default reads the package build output. Whether or not
+ * a build is present where the suite runs, the call must not error, and
+ * any resulting registration must use the real block name.
+ */
+ VideoPress_Initializer::register_videopress_playlist_block();
+ if ( $registry->is_registered( 'videopress/playlist' ) ) {
+ $registry->unregister( 'videopress/playlist' );
+ }
+
+ $nameless = $this->create_metadata_fixture( array( 'title' => 'No name' ) );
+ VideoPress_Initializer::register_videopress_playlist_block( $nameless );
+ $this->assertFalse( $registry->is_registered( 'videopress/playlist' ) );
+
+ $this->remove_metadata_fixture( $nameless );
+ }
+
+ /** Tests that the player and playlist items are rendered. */
+ public function test_renders_player_and_items() {
+ $html = $this->render(
+ array(
+ 'videos' => array(
+ array(
+ 'guid' => 'abcd1234',
+ 'title' => 'First video',
+ ),
+ array( 'guid' => 'efgh5678' ),
+ ),
+ )
+ );
+
+ $this->assertStringContainsString( 'wp-block-videopress-playlist', $html );
+
+ // The player starts on the first video, without autoplay.
+ $this->assertSame( 1, preg_match( '/