diff --git a/projects/plugins/jetpack/changelog/add-podcast-episode-block b/projects/plugins/jetpack/changelog/add-podcast-episode-block new file mode 100644 index 000000000000..10a0837dfbf1 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-podcast-episode-block @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add Podcast Episode block to embed a single podcast episode from an audio or video file with Podcasting 2.0 metadata. diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json new file mode 100644 index 000000000000..46694f731a70 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "jetpack/podcast-episode", + "title": "Podcast Episode", + "description": "Embed a single podcast episode from an audio or video file, with Podcasting 2.0 metadata.", + "keywords": [ "audio", "podcast", "episode" ], + "version": "1.0.0", + "textdomain": "jetpack", + "category": "embed", + "icon": "", + "usesContext": [ "postId", "postType", "queryId" ], + "supports": { + "spacing": { + "padding": true, + "margin": true + }, + "anchor": true, + "customClassName": true, + "className": true, + "html": false, + "multiple": true, + "reusable": true + }, + "attributes": { + "mediaId": { + "type": "integer" + }, + "mediaUrl": { + "type": "string" + }, + "mediaType": { + "type": "string", + "enum": [ "audio", "video" ] + }, + "mediaMimeType": { + "type": "string" + }, + "mediaSize": { + "type": "integer" + }, + "episodeNumber": { + "type": "integer" + }, + "seasonNumber": { + "type": "integer" + }, + "episodeType": { + "type": "string", + "enum": [ "full", "trailer", "bonus" ], + "default": "full" + }, + "explicit": { + "type": "boolean", + "default": false + }, + "duration": { + "type": "string", + "default": "" + }, + "transcriptUrl": { + "type": "string", + "default": "" + }, + "transcriptType": { + "type": "string", + "enum": [ "text/vtt", "text/html", "application/srt", "application/json" ], + "default": "text/vtt" + }, + "chaptersUrl": { + "type": "string", + "default": "" + }, + "locationName": { + "type": "string", + "default": "" + }, + "license": { + "type": "string", + "default": "" + }, + "licenseUrl": { + "type": "string", + "default": "" + }, + "people": { + "type": "array", + "default": [] + }, + "showPoster": { + "type": "boolean", + "default": true + }, + "coverArt": { + "type": "object", + "default": {} + } + }, + "example": { + "attributes": { + "episodeNumber": 1, + "seasonNumber": 1, + "episodeType": "full", + "duration": "11:25" + } + } +} diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js new file mode 100644 index 000000000000..1c7af391a0b2 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -0,0 +1,523 @@ +import apiFetch from '@wordpress/api-fetch'; +import { + BlockControls, + InspectorControls, + MediaPlaceholder, + MediaReplaceFlow, + MediaUpload, + MediaUploadCheck, + useBlockProps, +} from '@wordpress/block-editor'; +import { + BaseControl, + Button, + PanelBody, + Placeholder, + SelectControl, + TextControl, + ToggleControl, + ToolbarGroup, +} from '@wordpress/components'; +import { store as coreStore, useEntityProp } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { convertSecondsToTimeCode } from '../../shared/components/media-player-control/utils'; +import { getValidatedAttributes } from '../../shared/get-validated-attributes'; +import metadata from './block.json'; +import { microphone } from './icons'; + +const AUDIO_VIDEO_MIME_TYPES = [ 'audio', 'video' ]; + +const EPISODE_TYPE_OPTIONS = [ + { label: __( 'Full', 'jetpack' ), value: 'full' }, + { label: __( 'Trailer', 'jetpack' ), value: 'trailer' }, + { label: __( 'Bonus', 'jetpack' ), value: 'bonus' }, +]; + +const TRANSCRIPT_TYPE_OPTIONS = [ + { label: __( 'WebVTT (text/vtt)', 'jetpack' ), value: 'text/vtt' }, + { label: __( 'HTML (text/html)', 'jetpack' ), value: 'text/html' }, + { label: __( 'SRT (application/srt)', 'jetpack' ), value: 'application/srt' }, + { label: __( 'JSON (application/json)', 'jetpack' ), value: 'application/json' }, +]; + +const PERSON_ROW_STYLE = { marginBottom: '1em' }; + +function PeopleEditor( { people, onChange } ) { + const updatePerson = ( index, patch ) => { + const next = people.map( ( person, i ) => ( i === index ? { ...person, ...patch } : person ) ); + onChange( next ); + }; + const removePerson = index => onChange( people.filter( ( _, i ) => i !== index ) ); + const addPerson = () => onChange( [ ...people, { name: '', role: '', href: '', img: '' } ] ); + + return ( + <> + { people.map( ( person, index ) => ( +
+ updatePerson( index, { name } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + updatePerson( index, { role } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + updatePerson( index, { href } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + updatePerson( index, { img } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + +
+ ) ) } + + + ); +} + +export default function PodcastEpisodeEdit( { attributes, setAttributes, context } ) { + const validated = getValidatedAttributes( metadata.attributes, attributes ); + const { + mediaId, + mediaUrl, + mediaType, + mediaMimeType, + episodeNumber, + seasonNumber, + episodeType, + explicit, + duration, + transcriptUrl, + transcriptType, + chaptersUrl, + locationName, + license, + licenseUrl, + people, + showPoster, + coverArt, + } = validated; + + const { postId, postType } = context || {}; + + const [ postTitle ] = useEntityProp( 'postType', postType, 'title', postId ); + const [ postDate ] = useEntityProp( 'postType', postType, 'date', postId ); + const [ authorId ] = useEntityProp( 'postType', postType, 'author', postId ); + + const postAuthor = useSelect( + select => { + const author = authorId ? select( coreStore ).getUser( authorId ) : null; + return author?.name || ''; + }, + [ authorId ] + ); + + const showCoverUrl = + ( typeof window !== 'undefined' && window.jetpackPodcastEpisodeData?.showCoverUrl ) || ''; + const coverArtUrl = coverArt?.url || showCoverUrl; + + const blockProps = useBlockProps(); + const [ uploadError, setUploadError ] = useState( null ); + + const onSelectMedia = async media => { + if ( ! media || ! media.url ) { + return; + } + const type = media.type === 'video' ? 'video' : 'audio'; + + // `fileLength` on the attachment shim is the ID3 `length_formatted` string + // (e.g. "12:00"); fall back to computing from seconds if only a number is + // available. + const nextDuration = + duration || + ( typeof media.fileLength === 'string' && media.fileLength ) || + ( media.duration ? convertSecondsToTimeCode( media.duration ) : '' ); + + const immediate = { + mediaId: media.id, + mediaUrl: media.url, + mediaType: type, + mediaMimeType: media.mime || media.mime_type || '', + mediaSize: media.filesizeInBytes || media.filesize_in_bytes || undefined, + duration: nextDuration, + }; + setAttributes( immediate ); + + if ( ! media.id ) { + return; + } + + // Backfill empty audio metadata from the attachment's ID3 data + // (parsed by WordPress via wp_read_audio_metadata on upload). + try { + const attachment = await apiFetch( { path: `/wp/v2/media/${ media.id }` } ); + const details = attachment?.media_details || {}; + + const patch = {}; + + if ( ! immediate.duration && details.length_formatted ) { + patch.duration = details.length_formatted; + } else if ( ! immediate.duration && details.length ) { + patch.duration = convertSecondsToTimeCode( details.length ); + } + + if ( ! immediate.mediaSize && details.filesize ) { + patch.mediaSize = Number( details.filesize ); + } + + if ( ! immediate.mediaMimeType && attachment?.mime_type ) { + patch.mediaMimeType = attachment.mime_type; + } + + if ( Object.keys( patch ).length ) { + setAttributes( patch ); + } + } catch { + // Non-fatal: media metadata is a nice-to-have, the user can fill fields manually. + } + }; + + if ( ! postId || ! postType ) { + return ( +
+ +
+ ); + } + + if ( ! mediaUrl ) { + return ( +
+ setUploadError( message ) } + notices={ + uploadError ?
{ uploadError }
: null + } + /> +
+ ); + } + + const dateSettings = getDateSettings(); + + return ( +
+ + + setUploadError( message ) } + name={ __( 'Replace audio/video', 'jetpack' ) } + /> + + + + + + + setAttributes( { + seasonNumber: value === '' ? undefined : Number( value ), + } ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + setAttributes( { + episodeNumber: value === '' ? undefined : Number( value ), + } ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { episodeType: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { explicit: value } ) } + __nextHasNoMarginBottom + /> + setAttributes( { showPoster: value } ) } + __nextHasNoMarginBottom + /> + { showPoster && ( + + { __( 'Cover art', 'jetpack' ) } + + + setAttributes( { + coverArt: media?.url ? { id: media.id, url: media.url } : {}, + } ) + } + allowedTypes={ [ 'image' ] } + value={ coverArt?.id } + render={ ( { open } ) => ( +
+ { coverArtUrl && ( + + ) } + + { coverArt?.url && ( + + ) } +
+ ) } + /> +
+

+ { __( + 'Defaults to the show cover art set in Settings → Writing → Podcasting.', + 'jetpack' + ) } +

+
+ ) } +
+ + + setAttributes( { duration: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + + setAttributes( { transcriptUrl: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { transcriptType: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { chaptersUrl: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { locationName: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { license: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { licenseUrl: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + { __( 'People', 'jetpack' ) } + setAttributes( { people: value } ) } + /> + + +
+ +
+ { showPoster && coverArtUrl && ( +
+ +
+ ) } +
+ { ( seasonNumber || episodeNumber || episodeType !== 'full' || explicit ) && ( +

+ { seasonNumber ? ( + + { sprintf( + /* translators: %d: season number. */ + __( 'Season %d', 'jetpack' ), + seasonNumber + ) } + + ) : null } + { episodeNumber ? ( + + { sprintf( + /* translators: %d: episode number. */ + __( 'Episode %d', 'jetpack' ), + episodeNumber + ) } + + ) : null } + { episodeType === 'trailer' && ( + + { __( 'Trailer', 'jetpack' ) } + + ) } + { episodeType === 'bonus' && ( + + { __( 'Bonus', 'jetpack' ) } + + ) } + { explicit && ( + + { __( 'E', 'jetpack' ) } + + ) } +

+ ) } + +

+ { postTitle || __( 'Untitled episode', 'jetpack' ) } +

+ + { ( postAuthor || postDate || duration ) && ( +

+ { postAuthor && ( + { postAuthor } + ) } + { postDate && ( + + ) } + { duration && ( + { duration } + ) } +

+ ) } + +
+ { mediaType === 'video' ? ( +
+ +

+ { __( 'Add episode show notes in the post content below.', 'jetpack' ) } +

+
+
+
+ ); +} diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.js new file mode 100644 index 000000000000..2f2dd71b163a --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.js @@ -0,0 +1,12 @@ +import { registerJetpackBlockFromMetadata } from '../../shared/register-jetpack-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +import './style.scss'; +import './editor.scss'; + +registerJetpackBlockFromMetadata( metadata, { + edit, + save, +} ); diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss new file mode 100644 index 000000000000..f17649e92686 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss @@ -0,0 +1,32 @@ +/** + * Podcast Episode block — editor-only styles. + */ + +.wp-block-jetpack-podcast-episode { + + .jetpack-podcast-episode__person-editor { + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + } + + .jetpack-podcast-episode__notes-hint { + margin-top: 12px; + color: #757575; + font-style: italic; + font-size: 13px; + } + + .jetpack-podcast-episode__cover-picker { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; + } + + .jetpack-podcast-episode__cover-preview { + max-width: 120px; + height: auto; + border-radius: 4px; + } +} diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/index.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/index.js new file mode 100644 index 000000000000..c706838433a7 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/index.js @@ -0,0 +1 @@ +export { default as microphone } from './microphone'; diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/microphone.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/microphone.js new file mode 100644 index 000000000000..28f75be952af --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/microphone.js @@ -0,0 +1,7 @@ +import { Path, SVG } from '@wordpress/primitives'; + +export default ( + + + +); diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php new file mode 100644 index 000000000000..ea8953a13ba6 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -0,0 +1,317 @@ +is_wpcom_platform() ) { + return; + } + + Blocks::jetpack_register_block( + __DIR__, + array( + 'render_callback' => __NAMESPACE__ . '\render_block', + // Reuse the core media element styles for the audio/video player. + 'style' => 'wp-mediaelement', + ) + ); +} +add_action( 'init', __NAMESPACE__ . '\register_block' ); + +/** + * Expose the show-level cover art URL to the block editor so the preview can + * fall back to it when no episode-specific cover art is set. + */ +function enqueue_editor_data() { + $show_cover_url = (string) get_option( 'podcasting_image', '' ); + $payload = wp_json_encode( + array( 'showCoverUrl' => $show_cover_url ), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT + ); + wp_add_inline_script( + 'wp-blocks', + 'window.jetpackPodcastEpisodeData = ' . $payload . ';', + 'before' + ); +} +add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\enqueue_editor_data' ); + +/** + * Podcast Episode block render callback. + * + * Pulls title, author, and date from the surrounding post — the post is the + * episode. Cover art falls back to the show-level podcasting_image option when + * the block has no episode-specific override. + * + * @param array $attributes Block attributes. + * @param string $content Inner content (fallback direct-link markup from save.js). + * @param \WP_Block $block The parsed block instance, used to read post context. + * @return string + */ +function render_block( $attributes, $content, $block = null ) { + // Outside the frontend, fall back to the saved direct link so RSS / email / REST export stays + // simple and predictable. + if ( ! Request::is_frontend() ) { + return $content; + } + + if ( empty( $attributes['mediaUrl'] ) ) { + return ''; + } + + // Resolve the post that backs this episode. Prefer block context (set by Query Loop / singular + // templates / post-bound block contexts) and fall back to the global loop for direct theme + // rendering. With no resolvable post, the block has nothing to display. + $post_id = 0; + if ( $block && isset( $block->context['postId'] ) ) { + $post_id = (int) $block->context['postId']; + } + if ( ! $post_id ) { + $post_id = (int) get_the_ID(); + } + if ( ! $post_id ) { + return ''; + } + $post = get_post( $post_id ); + if ( ! $post ) { + return ''; + } + + $media_url = esc_url_raw( $attributes['mediaUrl'] ); + if ( ! wp_http_validate_url( $media_url ) ) { + return ''; + } + + $media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio'; + $mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : ''; + $episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0; + $season_number = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0; + $episode_type = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full'; + $is_explicit = ! empty( $attributes['explicit'] ); + $duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : ''; + $show_poster = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] ); + $transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : ''; + $chapters_url = isset( $attributes['chaptersUrl'] ) ? esc_url_raw( $attributes['chaptersUrl'] ) : ''; + $location_name = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : ''; + $license = isset( $attributes['license'] ) ? (string) $attributes['license'] : ''; + $license_url = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : ''; + $people = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array(); + + // Pull display content from the resolved post (block context or global loop). + $title = get_the_title( $post ); + $author_name = get_the_author_meta( 'display_name', (int) $post->post_author ); + $publish_date_iso = get_the_date( 'c', $post ); + $publish_date = get_the_date( '', $post ); + + // Cover art: episode-specific override → show-level podcasting_image option → none. + $image_url = ''; + if ( $show_poster ) { + if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) { + $image_url = esc_url_raw( $attributes['coverArt']['url'] ); + } else { + $image_url = (string) get_option( 'podcasting_image', '' ); + } + } + + $block_classname = Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attributes ); + $is_amp = Blocks::is_amp_request(); + + ob_start(); + ?> +
+
+ +
+ +
+ + +
+ +

+ + + + + + + + + + + + + + + + + + +

+ + + +

+ + + + + + +
+ + + + + +
+ + +
    + +
  • + + + + + + + + + + + +
  • + +
+ + + + + +
+
+
+ + { mediaUrl } + + ); +} diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/style.scss b/projects/plugins/jetpack/extensions/blocks/podcast-episode/style.scss new file mode 100644 index 000000000000..28b894f82633 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/style.scss @@ -0,0 +1,172 @@ +/** + * Podcast Episode block shared styles (editor + front-end). + */ +@use "@automattic/jetpack-base-styles/gutenberg-base-styles" as gb; + +$jetpack-podcast-episode-radius: 6px; +$jetpack-podcast-episode-gap: 16px; + +.wp-block-jetpack-podcast-episode { + + .jetpack-podcast-episode { + display: flex; + gap: $jetpack-podcast-episode-gap; + align-items: flex-start; + padding: $jetpack-podcast-episode-gap; + border: 1px solid gb.$gray-200; + border-radius: $jetpack-podcast-episode-radius; + background-color: gb.$white; + + @media (max-width: 600px) { + flex-direction: column; + } + } + + .jetpack-podcast-episode__poster { + flex: 0 0 auto; + margin: 0; + inline-size: 160px; + max-inline-size: 40%; + + img { + inline-size: 100%; + block-size: auto; + border-radius: calc(#{$jetpack-podcast-episode-radius} - 2px); + display: block; + } + + @media (max-width: 600px) { + inline-size: 100%; + max-inline-size: 100%; + } + } + + .jetpack-podcast-episode__body { + flex: 1 1 auto; + min-inline-size: 0; + display: flex; + flex-direction: column; + gap: 8px; + } + + .jetpack-podcast-episode__meta-line { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + font-size: 0.85em; + color: gb.$gray-700; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .jetpack-podcast-episode__badge { + display: inline-flex; + align-items: center; + padding-inline: 8px; + padding-block: 2px; + font-size: 0.75em; + font-weight: 600; + line-height: 1.4; + border-radius: 999px; + background-color: gb.$gray-100; + color: gb.$gray-900; + } + + .jetpack-podcast-episode__badge--trailer { + background-color: #e7f5ff; + color: #0073aa; + } + + .jetpack-podcast-episode__badge--bonus { + background-color: #fff4e6; + color: #b35900; + } + + .jetpack-podcast-episode__badge--explicit { + background-color: gb.$gray-900; + color: gb.$white; + font-family: monospace; + } + + .jetpack-podcast-episode__title { + margin: 0; + font-size: 1.4em; + line-height: 1.3; + } + + .jetpack-podcast-episode__byline { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + font-size: 0.9em; + color: gb.$gray-700; + } + + .jetpack-podcast-episode__player { + margin-block: 4px; + + audio, + video { + inline-size: 100%; + max-inline-size: 100%; + display: block; + } + + video { + border-radius: calc(#{$jetpack-podcast-episode-radius} - 2px); + } + } + + .jetpack-podcast-episode__summary { + margin: 0; + font-weight: 500; + } + + .jetpack-podcast-episode__description { + margin: 0; + + p:last-child { + margin-block-end: 0; + } + } + + .jetpack-podcast-episode__people { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 12px; + } + + .jetpack-podcast-episode__person { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9em; + + img { + inline-size: 28px; + block-size: 28px; + border-radius: 50%; + object-fit: cover; + } + } + + .jetpack-podcast-episode__person-role { + color: gb.$gray-700; + font-size: 0.85em; + } + + .jetpack-podcast-episode__links { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 0.9em; + } +} diff --git a/projects/plugins/jetpack/extensions/index.json b/projects/plugins/jetpack/extensions/index.json index 7323b49114a5..4d8417cd26c2 100644 --- a/projects/plugins/jetpack/extensions/index.json +++ b/projects/plugins/jetpack/extensions/index.json @@ -26,6 +26,7 @@ "opentable", "payments", "pinterest", + "podcast-episode", "podcast-player", "premium-content", "rating-star", diff --git a/projects/plugins/jetpack/extensions/shared/components/media-player-control/utils.js b/projects/plugins/jetpack/extensions/shared/components/media-player-control/utils.js index 6b93b0c002c0..09115fc01c14 100644 --- a/projects/plugins/jetpack/extensions/shared/components/media-player-control/utils.js +++ b/projects/plugins/jetpack/extensions/shared/components/media-player-control/utils.js @@ -1,4 +1,65 @@ /* global mejs */ -export const convertSecondsToTimeCode = mejs.Utils.secondsToTimeCode; -export const convertTimeCodeToSeconds = mejs.Utils.timeCodeToSeconds; +const getSecondsToTimeCode = () => + typeof mejs !== 'undefined' && typeof mejs?.Utils?.secondsToTimeCode === 'function' + ? mejs.Utils.secondsToTimeCode + : null; + +const getTimeCodeToSeconds = () => + typeof mejs !== 'undefined' && typeof mejs?.Utils?.timeCodeToSeconds === 'function' + ? mejs.Utils.timeCodeToSeconds + : null; + +const fallbackConvertSecondsToTimeCode = seconds => { + const totalSeconds = Math.max( 0, Math.floor( Number( seconds ) || 0 ) ); + const hours = Math.floor( totalSeconds / 3600 ); + const minutes = Math.floor( ( totalSeconds % 3600 ) / 60 ); + const remainingSeconds = totalSeconds % 60; + + const paddedMinutes = String( minutes ).padStart( 2, '0' ); + const paddedSeconds = String( remainingSeconds ).padStart( 2, '0' ); + + if ( hours > 0 ) { + return `${ String( hours ).padStart( 2, '0' ) }:${ paddedMinutes }:${ paddedSeconds }`; + } + + return `${ paddedMinutes }:${ paddedSeconds }`; +}; + +const fallbackConvertTimeCodeToSeconds = timecode => { + if ( typeof timecode !== 'string' ) { + return 0; + } + + const parts = timecode.split( ':' ).map( part => part.trim() ); + + if ( + parts.length < 1 || + parts.length > 3 || + parts.some( part => part === '' || Number.isNaN( Number( part ) ) ) + ) { + return 0; + } + + return parts.reduce( ( total, part ) => total * 60 + Number( part ), 0 ); +}; + +// MediaElement.js loads on-demand in the editor, so look the helpers up at call +// time rather than at module-evaluation time. Reading them at import would throw +// ReferenceError if the importing module evaluates before mediaelement is on the +// page, which can happen for blocks that don't otherwise depend on `wp-mediaelement`. +export const convertSecondsToTimeCode = seconds => { + const secondsToTimeCode = getSecondsToTimeCode(); + + return secondsToTimeCode + ? secondsToTimeCode( seconds ) + : fallbackConvertSecondsToTimeCode( seconds ); +}; + +export const convertTimeCodeToSeconds = timecode => { + const timeCodeToSeconds = getTimeCodeToSeconds(); + + return timeCodeToSeconds + ? timeCodeToSeconds( timecode ) + : fallbackConvertTimeCodeToSeconds( timecode ); +}; diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/Podcast_Episode_Block_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/Podcast_Episode_Block_Test.php new file mode 100644 index 000000000000..9636f447ad9b --- /dev/null +++ b/projects/plugins/jetpack/tests/php/extensions/blocks/Podcast_Episode_Block_Test.php @@ -0,0 +1,236 @@ + 'https://example.com/episode.mp3', + ); + + /** + * Force the is_frontend check to return true for tests that exercise the + * render path. Removed in tear_down to keep tests isolated. + */ + public function set_up() { + parent::set_up(); + add_filter( 'jetpack_is_frontend', '__return_true' ); + } + + /** + * Remove the frontend filter and reset any global state touched by tests. + */ + public function tear_down() { + remove_filter( 'jetpack_is_frontend', '__return_true' ); + delete_option( 'podcasting_image' ); + parent::tear_down(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Create and return a published post ID for use as episode context. + * + * @param string $title Optional post title. + * @return int + */ + private function create_episode_post( $title = 'Test Episode' ) { + return wp_insert_post( + array( + 'post_title' => $title, + 'post_status' => 'publish', + ) + ); + } + + /** + * Build a minimal WP_Block-like object carrying just enough context for the + * render callback to resolve the post ID from. + * + * @param int $post_id Post ID to embed in the context. + * @return object + */ + private function make_block_context( $post_id ) { + return (object) array( + 'context' => array( 'postId' => $post_id ), + ); + } + + // ------------------------------------------------------------------------- + // Non-frontend passthrough + // ------------------------------------------------------------------------- + + /** + * When the request is not a frontend request (e.g. REST export, RSS, email), + * render_block must return the raw $content unchanged. + */ + public function test_non_frontend_returns_content_unchanged() { + // Override the filter added in set_up to simulate a non-frontend context. + remove_filter( 'jetpack_is_frontend', '__return_true' ); + add_filter( 'jetpack_is_frontend', '__return_false' ); + + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + array(), + 'Listen' + ); + + remove_filter( 'jetpack_is_frontend', '__return_false' ); + add_filter( 'jetpack_is_frontend', '__return_true' ); + + $this->assertSame( 'Listen', $result ); + } + + // ------------------------------------------------------------------------- + // mediaUrl validation + // ------------------------------------------------------------------------- + + /** + * An empty mediaUrl should short-circuit to an empty string on the frontend. + */ + public function test_empty_media_url_returns_empty_string() { + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + array( 'mediaUrl' => '' ), + 'fallback' + ); + + $this->assertSame( '', $result ); + } + + /** + * A mediaUrl that is not a valid HTTP(S) URL (fails wp_http_validate_url) + * should return an empty string even when a post context is available. + */ + public function test_invalid_media_url_returns_empty_string() { + $post_id = $this->create_episode_post(); + + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + array( 'mediaUrl' => 'not-a-valid-url' ), + 'fallback', + $this->make_block_context( $post_id ) + ); + + wp_delete_post( $post_id, true ); + + $this->assertSame( '', $result ); + } + + // ------------------------------------------------------------------------- + // Post context resolution + // ------------------------------------------------------------------------- + + /** + * Without a block context and without a global post, render_block should + * return an empty string rather than crash. + */ + public function test_no_post_context_returns_empty_string() { + $original_post = $GLOBALS['post'] ?? null; + $GLOBALS['post'] = null; + + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + $this->default_attrs, + 'fallback' + ); + + $GLOBALS['post'] = $original_post; + + $this->assertSame( '', $result ); + } + + // ------------------------------------------------------------------------- + // Cover art fallback chain + // ------------------------------------------------------------------------- + + /** + * When coverArt has a URL, that URL should appear in the rendered markup and + * the show-level cover should not. + */ + public function test_episode_cover_art_takes_precedence_over_show_cover() { + update_option( 'podcasting_image', 'https://example.com/show-cover.jpg' ); + + $post_id = $this->create_episode_post(); + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + array_merge( + $this->default_attrs, + array( + 'coverArt' => array( + 'id' => 42, + 'url' => 'https://example.com/episode-cover.jpg', + ), + ) + ), + '', + $this->make_block_context( $post_id ) + ); + wp_delete_post( $post_id, true ); + + $this->assertStringContainsString( 'https://example.com/episode-cover.jpg', $result ); + $this->assertStringNotContainsString( 'https://example.com/show-cover.jpg', $result ); + } + + /** + * When no episode cover art is set, the show-level podcasting_image option + * should be used as the fallback. + */ + public function test_show_cover_used_when_no_episode_cover_art() { + update_option( 'podcasting_image', 'https://example.com/show-cover.jpg' ); + + $post_id = $this->create_episode_post(); + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + $this->default_attrs, + '', + $this->make_block_context( $post_id ) + ); + wp_delete_post( $post_id, true ); + + $this->assertStringContainsString( 'https://example.com/show-cover.jpg', $result ); + } + + /** + * A malformed coverArt attribute (e.g. a string from older serialised content + * or a manual block edit) must not raise PHP warnings and must fall back to + * the show-level cover art. + */ + public function test_malformed_cover_art_attribute_falls_back_to_show_cover() { + update_option( 'podcasting_image', 'https://example.com/show-cover.jpg' ); + + $post_id = $this->create_episode_post(); + $result = \Automattic\Jetpack\Extensions\Podcast_Episode\render_block( + array_merge( + $this->default_attrs, + array( 'coverArt' => 'malformed-string-value' ) + ), + '', + $this->make_block_context( $post_id ) + ); + wp_delete_post( $post_id, true ); + + // Must fall back gracefully without PHP warnings/errors. + $this->assertStringContainsString( 'https://example.com/show-cover.jpg', $result ); + } +}