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( + '
    %s%s
    ', + 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
    %3$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 && ( + + ) } +
    +

    + { __( + '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 && ( +
    +
    ` + + `
    `; + + 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( '/]+src="([^"]+)"/', $html, $matches ) ); + $this->assertStringContainsString( 'videopress.com/embed/abcd1234', $matches[1] ); + $this->assertStringContainsString( 'autoPlay=0', $matches[1] ); + + // Each item carries an autoplaying embed URL for the click/advance handlers. + $this->assertStringContainsString( 'data-guid="abcd1234"', $html ); + $this->assertStringContainsString( 'data-guid="efgh5678"', $html ); + $this->assertSame( 2, substr_count( $html, 'autoPlay=1' ) ); + + // Titles fall back to a numbered label. + $this->assertStringContainsString( 'First video', $html ); + $this->assertStringContainsString( 'Video 2', $html ); + + // Sequence defaults: auto-advance on, loop off. + $this->assertStringContainsString( 'data-auto-advance="1"', $html ); + $this->assertStringContainsString( 'data-loop="0"', $html ); + } + + /** Tests that items carry the placeholders the view script fills with live metadata. */ + public function test_render_includes_metadata_placeholders() { + $html = $this->render( + array( 'videos' => array( array( 'guid' => 'abcd1234' ) ) ) + ); + + $this->assertStringContainsString( 'videopress-playlist__item-badge', $html ); + $this->assertStringContainsString( 'videopress-playlist__item-duration', $html ); + $this->assertStringContainsString( 'videopress-playlist__item-thumb', $html ); + $this->assertStringContainsString( 'videopress-playlist__header', $html ); + } + + /** Tests that stored metadata renders as initial content. */ + public function test_render_uses_stored_metadata() { + $html = $this->render( + array( + 'videos' => array( + array( + 'guid' => 'abcd1234', + 'title' => 'Kiln loading', + 'durationMs' => 724000, + 'height' => 1080, + 'poster' => 'https://videos.files.wordpress.com/abcd1234/poster.jpg', + ), + array( + 'guid' => 'efgh5678', + 'durationMs' => 3660000, + 'height' => 2160, + ), + ), + ) + ); + + $this->assertStringContainsString( '12:04', $html ); + $this->assertStringContainsString( '1080p', $html ); + $this->assertStringContainsString( '4K', $html ); + $this->assertStringContainsString( 'poster.jpg', $html ); + $this->assertStringContainsString( 'data-duration-ms="724000"', $html ); + // Header: count and long-form total runtime (724000 + 3660000 ms ≈ 1 hr 13 min). + $this->assertStringContainsString( '2 videos', $html ); + $this->assertStringContainsString( '1 hr 13 min', $html ); + } + + /** Tests that layout, dark surface, and display toggles map to wrapper classes. */ + public function test_render_layout_and_display_classes() { + $base = array( 'videos' => array( array( 'guid' => 'abcd1234' ) ) ); + + $default_html = $this->render( $base ); + $this->assertStringContainsString( 'videopress-playlist--rail', $default_html ); + $this->assertStringNotContainsString( 'is-dark', $default_html ); + $this->assertStringNotContainsString( 'hide-thumbnails', $default_html ); + $this->assertStringNotContainsString( 'show-position', $default_html ); + + $custom_html = $this->render( + array_merge( + $base, + array( + 'layout' => 'grid', + 'darkSurface' => true, + 'showThumbnail' => false, + 'showResolution' => false, + 'showDuration' => false, + 'showTitle' => false, + 'showPosition' => true, + 'showTotalRuntime' => false, + ) + ) + ); + $this->assertStringContainsString( 'videopress-playlist--grid', $custom_html ); + $this->assertStringContainsString( 'is-dark', $custom_html ); + $this->assertStringContainsString( 'hide-thumbnails', $custom_html ); + $this->assertStringContainsString( 'hide-resolution', $custom_html ); + $this->assertStringContainsString( 'hide-duration', $custom_html ); + $this->assertStringContainsString( 'hide-titles', $custom_html ); + $this->assertStringContainsString( 'show-position', $custom_html ); + $this->assertStringContainsString( 'hide-runtime', $custom_html ); + + // Unknown layout values fall back to the rail layout. + $fallback_html = $this->render( array_merge( $base, array( 'layout' => 'bogus' ) ) ); + $this->assertStringContainsString( 'videopress-playlist--rail', $fallback_html ); + } + + /** Tests that autoAdvance and loop attributes reach the frontend dataset. */ + public function test_auto_advance_and_loop_attributes() { + $html = $this->render( + array( + 'videos' => array( array( 'guid' => 'abcd1234' ) ), + 'autoAdvance' => false, + 'loop' => true, + ) + ); + + $this->assertStringContainsString( 'data-auto-advance="0"', $html ); + $this->assertStringContainsString( 'data-loop="1"', $html ); + } + + /** Tests that entries without a valid 8-character GUID are dropped. */ + public function test_invalid_guids_are_dropped() { + $html = $this->render( + array( + 'videos' => array( + array( 'guid' => '">' ), + array( 'guid' => 'short' ), + 'not-an-array-entry', + array( 'title' => 'No guid at all' ), + array( 'guid' => 'abcd1234' ), + ), + ) + ); + + $this->assertStringNotContainsString( '', $html ); + $this->assertStringNotContainsString( 'short', $html ); + $this->assertSame( 1, substr_count( $html, 'data-src=' ) ); + $this->assertStringContainsString( 'data-guid="abcd1234"', $html ); + } + + /** Tests that video titles are escaped. */ + public function test_titles_are_escaped() { + $html = $this->render( + array( + 'videos' => array( + array( + 'guid' => 'abcd1234', + 'title' => '', + ), + ), + ) + ); + + $this->assertStringNotContainsString( '', $html ); + $this->assertStringContainsString( '<script>', $html ); + } + + /** Tests that a playlist without valid videos renders nothing. */ + public function test_empty_playlist_renders_nothing() { + $this->assertSame( '', trim( $this->render( array( 'videos' => array() ) ) ) ); + $this->assertSame( '', trim( $this->render( array() ) ) ); + $this->assertSame( '', trim( $this->render( array( 'videos' => array( array( 'guid' => 'bad' ) ) ) ) ) ); + } +} diff --git a/projects/packages/videopress/webpack.config.js b/projects/packages/videopress/webpack.config.js index a74af2d3022c..6d4acd836f15 100644 --- a/projects/packages/videopress/webpack.config.js +++ b/projects/packages/videopress/webpack.config.js @@ -67,6 +67,10 @@ module.exports = [ 'block-editor/blocks/video/index': './src/client/block-editor/blocks/video/index.ts', 'block-editor/blocks/video/view': './src/client/block-editor/blocks/video/view.ts', + // Playlist block + 'block-editor/blocks/playlist/index': './src/client/block-editor/blocks/playlist/index.ts', + 'block-editor/blocks/playlist/view': './src/client/block-editor/blocks/playlist/view.ts', + 'lib/token-bridge': './src/client/lib/token-bridge/index.ts', 'lib/player-bridge': './src/client/lib/player-bridge/index.ts',