From 4b323a6bdd52109936fcb2a2aa222b37f6816639 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Wed, 29 Apr 2026 13:29:01 +0200 Subject: [PATCH 01/14] Add Podcast Episode block --- .../changelog/add-podcast-episode-block | 4 + .../blocks/podcast-episode/block.json | 135 ++++ .../extensions/blocks/podcast-episode/edit.js | 583 ++++++++++++++++++ .../blocks/podcast-episode/editor.js | 12 + .../blocks/podcast-episode/editor.scss | 18 + .../blocks/podcast-episode/icons/index.js | 1 + .../podcast-episode/icons/microphone.js | 7 + .../podcast-episode/podcast-episode.php | 284 +++++++++ .../extensions/blocks/podcast-episode/save.js | 20 + .../blocks/podcast-episode/style.scss | 172 ++++++ .../plugins/jetpack/extensions/index.json | 1 + 11 files changed, 1237 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/add-podcast-episode-block create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.js create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/index.js create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/icons/microphone.js create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/save.js create mode 100644 projects/plugins/jetpack/extensions/blocks/podcast-episode/style.scss 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..fdfa1468974b --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-podcast-episode-block @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Podcast Episode block: new 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..4fbfd6ed9df9 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json @@ -0,0 +1,135 @@ +{ + "$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": "", + "supports": { + "align": [ "wide", "full" ], + "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" + }, + "title": { + "type": "string", + "default": "" + }, + "summary": { + "type": "string", + "default": "" + }, + "description": { + "type": "string", + "default": "" + }, + "author": { + "type": "string", + "default": "" + }, + "episodeNumber": { + "type": "integer" + }, + "seasonNumber": { + "type": "integer" + }, + "episodeType": { + "type": "string", + "enum": [ "full", "trailer", "bonus" ], + "default": "full" + }, + "explicit": { + "type": "boolean", + "default": false + }, + "publishDate": { + "type": "string" + }, + "guid": { + "type": "string" + }, + "duration": { + "type": "string", + "default": "" + }, + "imageId": { + "type": "integer" + }, + "imageUrl": { + "type": "string" + }, + "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 + } + }, + "example": { + "attributes": { + "title": "1. Welcome to the Jetpack Example Podcast", + "summary": "A short introduction to what this show is all about.", + "author": "Jetpack", + "episodeNumber": 1, + "seasonNumber": 1, + "episodeType": "full", + "duration": "11:25", + "imageUrl": "https://jetpackme.files.wordpress.com/2020/05/jetpack-example-podcast-cover.png?w=320" + } + } +} 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..6a0e991ece2f --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -0,0 +1,583 @@ +import apiFetch from '@wordpress/api-fetch'; +import { + BlockControls, + InspectorControls, + MediaPlaceholder, + MediaReplaceFlow, + MediaUpload, + MediaUploadCheck, + RichText, + useBlockProps, +} from '@wordpress/block-editor'; +import { + BaseControl, + Button, + DateTimePicker, + Dropdown, + PanelBody, + PanelRow, + SelectControl, + TextControl, + ToggleControl, + ToolbarButton, + ToolbarGroup, +} from '@wordpress/components'; +import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +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)', value: 'text/vtt' }, + { label: 'HTML (text/html)', value: 'text/html' }, + { label: 'SRT (application/srt)', value: 'application/srt' }, + { label: 'JSON (application/json)', value: 'application/json' }, +]; + +function formatSeconds( totalSeconds ) { + const n = Number( totalSeconds ); + if ( ! n || Number.isNaN( n ) ) { + return ''; + } + const seconds = Math.floor( n % 60 ); + const minutes = Math.floor( ( n / 60 ) % 60 ); + const hours = Math.floor( n / 3600 ); + const pad = v => String( v ).padStart( 2, '0' ); + return hours > 0 + ? `${ hours }:${ pad( minutes ) }:${ pad( seconds ) }` + : `${ minutes }:${ pad( seconds ) }`; +} + +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, isSelected } ) { + const validated = getValidatedAttributes( metadata.attributes, attributes ); + const { + mediaId, + mediaUrl, + mediaType, + mediaMimeType, + title, + summary, + description, + author, + episodeNumber, + seasonNumber, + episodeType, + explicit, + publishDate, + guid, + duration, + imageId, + imageUrl, + transcriptUrl, + transcriptType, + chaptersUrl, + locationName, + license, + licenseUrl, + people, + showPoster, + } = validated; + + const blockProps = useBlockProps( { className: 'wp-block-jetpack-podcast-episode' } ); + 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 ) || + formatSeconds( 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, + title: title || media.title || '', + }; + setAttributes( immediate ); + + if ( ! media.id ) { + return; + } + + // Backfill any empty metadata fields from the attachment's ID3 data (parsed + // by WordPress via wp_read_audio_metadata on upload). We never overwrite + // values the user has already typed. + try { + const attachment = await apiFetch( { path: `/wp/v2/media/${ media.id }` } ); + const details = attachment?.media_details || {}; + const id3 = details.length_formatted || details.length ? details : details.audio || details; + + const patch = {}; + + if ( ! immediate.duration && details.length_formatted ) { + patch.duration = details.length_formatted; + } else if ( ! immediate.duration && details.length ) { + patch.duration = formatSeconds( details.length ); + } + + if ( ! title && id3?.title ) { + patch.title = id3.title; + } + + if ( ! author && id3?.artist ) { + patch.author = id3.artist; + } + + 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. + } + }; + + const onSelectImage = image => { + if ( ! image || ! image.url ) { + return; + } + setAttributes( { imageId: image.id, imageUrl: image.url } ); + }; + + const clearImage = () => setAttributes( { imageId: undefined, imageUrl: undefined } ); + + if ( ! mediaUrl ) { + return ( +
+ setUploadError( message ) } + notices={ + uploadError ?
{ uploadError }
: null + } + /> +
+ ); + } + + const dateSettings = getDateSettings(); + + return ( +
+ + + setUploadError( message ) } + name={ __( 'Replace audio/video', 'jetpack' ) } + /> + + ( + + { imageUrl + ? __( 'Change cover art', 'jetpack' ) + : __( 'Add cover art', 'jetpack' ) } + + ) } + /> + + + + + + + + setAttributes( { + episodeNumber: value === '' ? undefined : Number( value ), + } ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + setAttributes( { + seasonNumber: value === '' ? undefined : Number( value ), + } ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { episodeType: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { explicit: value } ) } + __nextHasNoMarginBottom + /> + setAttributes( { author: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + setAttributes( { duration: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + ( + + ) } + renderContent={ () => ( + setAttributes( { publishDate: value } ) } + is12Hour={ dateSettings.formats.time.toLowerCase().includes( 'a' ) } + /> + ) } + /> + { publishDate && ( + + ) } + + setAttributes( { guid: value } ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + + setAttributes( { showPoster: value } ) } + __nextHasNoMarginBottom + /> + + ( + + + { imageUrl && ( + + ) } + + ) } + /> + + + + + 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 + /> + + setAttributes( { people: value } ) } + /> + + + + +
+ { showPoster && imageUrl && ( +
+ { +
+ ) } +
+ { ( seasonNumber || episodeNumber || episodeType !== 'full' || explicit ) && ( +

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

+ ) } + + setAttributes( { title: value } ) } + placeholder={ __( 'Episode title…', 'jetpack' ) } + allowedFormats={ [] } + /> + + { ( author || duration || publishDate || isSelected ) && ( +

+ { author && { author } } + { publishDate && ( + + ) } + { duration && ( + { duration } + ) } +

+ ) } + +
+ { mediaType === 'video' ? ( +
+ + setAttributes( { summary: value } ) } + placeholder={ __( 'Short episode summary (one or two sentences)…', 'jetpack' ) } + allowedFormats={ [] } + /> + + setAttributes( { description: value } ) } + placeholder={ __( 'Episode show notes…', '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..d400b3887cc5 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss @@ -0,0 +1,18 @@ +/** + * Podcast Episode block — editor-only styles. + */ + +.wp-block-jetpack-podcast-episode { + + .jetpack-podcast-episode__title[contenteditable="true"]:empty::before, + .jetpack-podcast-episode__summary[contenteditable="true"]:empty::before, + .jetpack-podcast-episode__description[contenteditable="true"]:empty::before { + color: rgba(0, 0, 0, 0.4); + } + + .jetpack-podcast-episode__person-editor { + padding: 12px; + border: 1px solid #ddd; + 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..2b8427a3801b --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -0,0 +1,284 @@ +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' ); + +/** + * Podcast Episode block render callback. + * + * @param array $attributes Block attributes. + * @param string $content Inner content (fallback direct-link markup from save.js). + * @return string + */ +function render_block( $attributes, $content ) { + // 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 ''; + } + + $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'] : ''; + $title = isset( $attributes['title'] ) ? (string) $attributes['title'] : ''; + $summary = isset( $attributes['summary'] ) ? (string) $attributes['summary'] : ''; + $description = isset( $attributes['description'] ) ? (string) $attributes['description'] : ''; + $author = isset( $attributes['author'] ) ? (string) $attributes['author'] : ''; + $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'] ); + $publish_date = isset( $attributes['publishDate'] ) ? (string) $attributes['publishDate'] : ''; + $duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : ''; + $image_url = isset( $attributes['imageUrl'] ) ? esc_url_raw( $attributes['imageUrl'] ) : ''; + $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(); + + $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports(); + $wrapper_style = ! empty( $wrapper_attributes['style'] ) ? $wrapper_attributes['style'] : ''; + $block_classname = Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attributes ); + $is_amp = Blocks::is_amp_request(); + + ob_start(); + ?> +
+ style="" + > +
+ +
+ <?php echo esc_attr( $title ); ?> +
+ + +
+ +

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

+ + + +

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

+ + + +
+ +
+ + + +
    + +
  • + + + + + + + + + + + +
  • + +
+ + + + + +
+
+
+ + { title || 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", From 5ae6aa074edb16521011601a448251b7838b64f6 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Mon, 4 May 2026 20:17:30 +0200 Subject: [PATCH 02/14] Podcast Episode: split ternary __() calls so i18n extractor can read string literals --- .../extensions/blocks/podcast-episode/edit.js | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js index 6a0e991ece2f..434827efa93a 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -272,13 +272,23 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec onSelect={ onSelectImage } allowedTypes={ [ 'image' ] } value={ imageId } - render={ ( { open } ) => ( - - { imageUrl - ? __( 'Change cover art', 'jetpack' ) - : __( 'Add cover art', 'jetpack' ) } - - ) } + render={ ( { open } ) => + imageUrl ? ( + + { __( 'Change cover art', 'jetpack' ) } + + ) : ( + + { __( 'Add cover art', 'jetpack' ) } + + ) + } /> @@ -402,11 +412,15 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec value={ imageId } render={ ( { open } ) => ( - + { imageUrl ? ( + + ) : ( + + ) } { imageUrl && ( - ) } - renderContent={ () => ( - setAttributes( { publishDate: value } ) } - is12Hour={ dateSettings.formats.time.toLowerCase().includes( 'a' ) } - /> - ) } - /> - { publishDate && ( - - ) } - - - setAttributes( { showPoster: value } ) } - __nextHasNoMarginBottom - /> - - ( - - { imageUrl ? ( - - ) : ( - - ) } - { imageUrl && ( - - ) } - - ) } - /> - - -
- { showPoster && imageUrl && ( + { showPoster && thumbnailUrl && (
- { + {
) }
@@ -507,7 +418,6 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec

{ seasonNumber ? ( - { /* translators: %d: season number */ } { __( 'Season', 'jetpack' ) } { seasonNumber } ) : null } @@ -537,22 +447,14 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec

) } - setAttributes( { title: value } ) } - placeholder={ __( 'Episode title…', 'jetpack' ) } - allowedFormats={ [] } - /> +

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

- { ( author || duration || publishDate || isSelected ) && ( + { ( postAuthor || duration ) && (

- { author && { author } } - { publishDate && ( - + { postAuthor && ( + { postAuthor } ) } { duration && ( { duration } @@ -566,7 +468,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec src={ mediaUrl } controls preload="metadata" - poster={ showPoster ? imageUrl : undefined } + poster={ showPoster ? thumbnailUrl : undefined } data-mime={ mediaMimeType || undefined } /> ) : ( @@ -574,22 +476,9 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, isSelec ) }

- setAttributes( { summary: value } ) } - placeholder={ __( 'Short episode summary (one or two sentences)…', 'jetpack' ) } - allowedFormats={ [] } - /> - - setAttributes( { description: value } ) } - placeholder={ __( 'Episode show notes…', 'jetpack' ) } - /> + { postExcerpt && ( +

{ postExcerpt }

+ ) }
diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss index d400b3887cc5..6a76fd68f954 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss @@ -4,12 +4,6 @@ .wp-block-jetpack-podcast-episode { - .jetpack-podcast-episode__title[contenteditable="true"]:empty::before, - .jetpack-podcast-episode__summary[contenteditable="true"]:empty::before, - .jetpack-podcast-episode__description[contenteditable="true"]:empty::before { - color: rgba(0, 0, 0, 0.4); - } - .jetpack-podcast-episode__person-editor { padding: 12px; border: 1px solid #ddd; diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php index 2b8427a3801b..ea98b542c68e 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -42,6 +42,9 @@ function register_block() { /** * Podcast Episode block render callback. * + * Pulls title, cover art, excerpt, and author from the surrounding post — + * the post is the episode. The block stores only audio + episode metadata. + * * @param array $attributes Block attributes. * @param string $content Inner content (fallback direct-link markup from save.js). * @return string @@ -53,6 +56,12 @@ function render_block( $attributes, $content ) { return $content; } + // The block only renders inside a singular post/page context, since it pulls + // title, cover art, and excerpt from the surrounding post. + if ( ! is_singular() ) { + return ''; + } + if ( empty( $attributes['mediaUrl'] ) ) { return ''; } @@ -64,17 +73,11 @@ function render_block( $attributes, $content ) { $media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio'; $mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : ''; - $title = isset( $attributes['title'] ) ? (string) $attributes['title'] : ''; - $summary = isset( $attributes['summary'] ) ? (string) $attributes['summary'] : ''; - $description = isset( $attributes['description'] ) ? (string) $attributes['description'] : ''; - $author = isset( $attributes['author'] ) ? (string) $attributes['author'] : ''; $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'] ); - $publish_date = isset( $attributes['publishDate'] ) ? (string) $attributes['publishDate'] : ''; $duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : ''; - $image_url = isset( $attributes['imageUrl'] ) ? esc_url_raw( $attributes['imageUrl'] ) : ''; $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'] ) : ''; @@ -83,6 +86,13 @@ function render_block( $attributes, $content ) { $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 surrounding post. + $title = get_the_title(); + $excerpt = get_the_excerpt(); + $author_name = get_the_author(); + $publish_date = get_the_date( 'c' ); + $image_url = $show_poster ? (string) get_the_post_thumbnail_url( null, 'large' ) : ''; + $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports(); $wrapper_style = ! empty( $wrapper_attributes['style'] ) ? $wrapper_attributes['style'] : ''; $block_classname = Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attributes ); @@ -98,7 +108,7 @@ class="" style="" >
- +
- +

- - - -
- -
+ +

diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/save.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/save.js index 4a5b03de2cba..dc5dd53a9002 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/save.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/save.js @@ -2,7 +2,7 @@ import { useBlockProps } from '@wordpress/block-editor'; import clsx from 'clsx'; export default function save( { attributes } ) { - const { mediaUrl, title } = attributes; + const { mediaUrl } = attributes; if ( ! mediaUrl ) { return null; } @@ -14,7 +14,7 @@ export default function save( { attributes } ) { className={ clsx( blockProps.className, 'jetpack-podcast-episode__direct-link' ) } href={ mediaUrl } > - { title || mediaUrl } + { mediaUrl } ); } From a5c2286c684eca3356d34722de97fba800cbcbfe Mon Sep 17 00:00:00 2001 From: Rob Pugh Date: Tue, 5 May 2026 19:05:07 -0400 Subject: [PATCH 04/14] Podcast Episode: use block context, reuse convertSecondsToTimeCode, restore poster size - Read post title/excerpt/featured image/author/date via useEntityProp with usesContext: ['postId', 'postType', 'queryId']. Same pattern as core's post-title/post-excerpt/post-featured-image blocks. Block now works inside Query Loops and site-editor singular templates, not just the post editor. - Replace local formatSeconds() with convertSecondsToTimeCode from extensions/shared/components/media-player-control/utils. - Render publish date in editor preview to match the frontend. - Use medium_large (768px) for the poster image instead of large (1024px); the cover renders at 256-512px CSS. - Hoist person-row inline style to a module const. Co-Authored-By: Claude Opus 4.7 --- .../blocks/podcast-episode/block.json | 1 + .../extensions/blocks/podcast-episode/edit.js | 86 +++++++++---------- .../podcast-episode/podcast-episode.php | 2 +- 3 files changed, 45 insertions(+), 44 deletions(-) diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json index 37c0208bfe0b..570d60261ea8 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json @@ -9,6 +9,7 @@ "textdomain": "jetpack", "category": "embed", "icon": "", + "usesContext": [ "postId", "postType", "queryId" ], "supports": { "spacing": { "padding": true, diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js index c8b61ef33838..a62989f49b1a 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -16,9 +16,12 @@ import { 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 { __ } 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'; @@ -38,19 +41,7 @@ const TRANSCRIPT_TYPE_OPTIONS = [ { label: 'JSON (application/json)', value: 'application/json' }, ]; -function formatSeconds( totalSeconds ) { - const n = Number( totalSeconds ); - if ( ! n || Number.isNaN( n ) ) { - return ''; - } - const seconds = Math.floor( n % 60 ); - const minutes = Math.floor( ( n / 60 ) % 60 ); - const hours = Math.floor( n / 3600 ); - const pad = v => String( v ).padStart( 2, '0' ); - return hours > 0 - ? `${ hours }:${ pad( minutes ) }:${ pad( seconds ) }` - : `${ minutes }:${ pad( seconds ) }`; -} +const PERSON_ROW_STYLE = { marginBottom: '1em' }; function PeopleEditor( { people, onChange } ) { const updatePerson = ( index, patch ) => { @@ -66,7 +57,7 @@ function PeopleEditor( { people, onChange } ) {
{ - const editor = select( 'core/editor' ); - const core = select( 'core' ); - if ( ! editor ) { - return {}; - } - const featuredId = editor.getEditedPostAttribute( 'featured_media' ); - const media = featuredId ? core.getMedia( featuredId ) : null; - const authorId = editor.getEditedPostAttribute( 'author' ); - const author = authorId ? core.getUser( authorId ) : null; - return { - postType: editor.getCurrentPostType(), - postTitle: editor.getEditedPostAttribute( 'title' ), - postExcerpt: editor.getEditedPostAttribute( 'excerpt' ), - postAuthor: author?.name || '', - thumbnailUrl: media?.source_url || '', - }; - }, [] ); + const { postId, postType } = context || {}; - const inPostContext = postType === 'post' || postType === 'page'; + // Pull display content from the surrounding post via block context. This is + // the same pattern core's post-title / post-excerpt / post-featured-image + // blocks use, which means this block also works inside Query Loops and + // site-editor singular templates, not just the post editor. + const [ postTitle ] = useEntityProp( 'postType', postType, 'title', postId ); + const [ postExcerpt ] = useEntityProp( 'postType', postType, 'excerpt', postId ); + const [ featuredId ] = useEntityProp( 'postType', postType, 'featured_media', postId ); + const [ postDate ] = useEntityProp( 'postType', postType, 'date', postId ); + const [ authorId ] = useEntityProp( 'postType', postType, 'author', postId ); + + const { thumbnailUrl, postAuthor } = useSelect( + select => { + const core = select( coreStore ); + const media = featuredId ? core.getMedia( featuredId ) : null; + const author = authorId ? core.getUser( authorId ) : null; + return { + thumbnailUrl: media?.source_url || '', + postAuthor: author?.name || '', + }; + }, + [ featuredId, authorId ] + ); const blockProps = useBlockProps( { className: 'wp-block-jetpack-podcast-episode' } ); const [ uploadError, setUploadError ] = useState( null ); @@ -170,7 +165,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { const nextDuration = duration || ( typeof media.fileLength === 'string' && media.fileLength ) || - formatSeconds( media.duration ); + ( media.duration ? convertSecondsToTimeCode( media.duration ) : '' ); const immediate = { mediaId: media.id, @@ -186,10 +181,8 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { return; } - // Backfill any empty audio metadata fields from the attachment's ID3 data - // (parsed by WordPress via wp_read_audio_metadata on upload). We never - // overwrite values the user has already typed, and we no longer touch - // title/author — those live on the post itself. + // 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 || {}; @@ -199,7 +192,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { if ( ! immediate.duration && details.length_formatted ) { patch.duration = details.length_formatted; } else if ( ! immediate.duration && details.length ) { - patch.duration = formatSeconds( details.length ); + patch.duration = convertSecondsToTimeCode( details.length ); } if ( ! immediate.mediaSize && details.filesize ) { @@ -218,14 +211,14 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { } }; - if ( ! inPostContext ) { + if ( ! postId ) { return (
@@ -257,6 +250,8 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { ); } + const dateSettings = getDateSettings(); + return (
@@ -451,11 +446,16 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes } ) { { postTitle || __( 'Untitled episode', 'jetpack' ) } - { ( postAuthor || duration ) && ( + { ( postAuthor || postDate || duration ) && (

{ postAuthor && ( { postAuthor } ) } + { postDate && ( + + ) } { duration && ( { duration } ) } diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php index ea98b542c68e..d38693ca6078 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -91,7 +91,7 @@ function render_block( $attributes, $content ) { $excerpt = get_the_excerpt(); $author_name = get_the_author(); $publish_date = get_the_date( 'c' ); - $image_url = $show_poster ? (string) get_the_post_thumbnail_url( null, 'large' ) : ''; + $image_url = $show_poster ? (string) get_the_post_thumbnail_url( null, 'medium_large' ) : ''; $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports(); $wrapper_style = ! empty( $wrapper_attributes['style'] ) ? $wrapper_attributes['style'] : ''; From e4cafed198b78d615fa7a5f37cae9322621e5634 Mon Sep 17 00:00:00 2001 From: Rob Pugh Date: Tue, 5 May 2026 19:26:02 -0400 Subject: [PATCH 05/14] Podcast Episode: read post via block context, defer mejs lookup, tighten i18n + a11y - render_block() now accepts \WP_Block and resolves the backing post from block context, falling back to the global loop. Drops the is_singular() guard so the block works inside Query Loops and feeds, and switches all template tags to post-aware variants (get_the_title($post), etc.) so the rendered episode matches the loop item, not whatever the global page query points at. - utils.js (extensions/shared/components/media-player-control): wrap the mejs.Utils helpers in arrow functions so the lookup happens at call time. Reading them at module-evaluation time would throw ReferenceError if the importing block evaluates before mediaelement loads on the page. - Editor: switch concatenated Season/Episode strings to sprintf( __( 'Season %d' ) ) so locales that reorder noun/number can translate cleanly. Drop `alt={ postTitle }` on the cover-art preview (the title is rendered immediately after as h3), use BaseControl .VisualLabel for the People section so the label does not point at a non-existent input id, gate the placeholder on both postId AND postType, and drop the redundant useBlockProps className argument. - Cover-art alt also dropped on the frontend for the same a11y reason. Co-Authored-By: Claude Opus 4.7 --- .../extensions/blocks/podcast-episode/edit.js | 28 +++++------ .../podcast-episode/podcast-episode.php | 46 ++++++++++++------- .../components/media-player-control/utils.js | 8 +++- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js index a62989f49b1a..2366701e265e 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -20,7 +20,7 @@ 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 { __ } from '@wordpress/i18n'; +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'; @@ -127,10 +127,6 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context const { postId, postType } = context || {}; - // Pull display content from the surrounding post via block context. This is - // the same pattern core's post-title / post-excerpt / post-featured-image - // blocks use, which means this block also works inside Query Loops and - // site-editor singular templates, not just the post editor. const [ postTitle ] = useEntityProp( 'postType', postType, 'title', postId ); const [ postExcerpt ] = useEntityProp( 'postType', postType, 'excerpt', postId ); const [ featuredId ] = useEntityProp( 'postType', postType, 'featured_media', postId ); @@ -150,7 +146,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context [ featuredId, authorId ] ); - const blockProps = useBlockProps( { className: 'wp-block-jetpack-podcast-episode' } ); + const blockProps = useBlockProps(); const [ uploadError, setUploadError ] = useState( null ); const onSelectMedia = async media => { @@ -211,7 +207,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context } }; - if ( ! postId ) { + if ( ! postId || ! postType ) { return (

- + + + { __( 'People', 'jetpack' ) } + setAttributes( { people: value } ) } @@ -405,7 +399,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context
{ showPoster && thumbnailUrl && (
- { +
) }
@@ -413,12 +407,14 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context

{ seasonNumber ? ( - { __( 'Season', 'jetpack' ) } { seasonNumber } + { /* translators: %d: season number. */ } + { sprintf( __( 'Season %d', 'jetpack' ), seasonNumber ) } ) : null } { episodeNumber ? ( - { __( 'Episode', 'jetpack' ) } { episodeNumber } + { /* translators: %d: episode number. */ } + { sprintf( __( 'Episode %d', 'jetpack' ), episodeNumber ) } ) : null } { episodeType === 'trailer' && ( diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php index d38693ca6078..6d1c8e449e83 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -45,24 +45,37 @@ function register_block() { * Pulls title, cover art, excerpt, and author from the surrounding post — * the post is the episode. The block stores only audio + episode metadata. * - * @param array $attributes Block attributes. - * @param string $content Inner content (fallback direct-link markup from save.js). + * @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 ) { +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; } - // The block only renders inside a singular post/page context, since it pulls - // title, cover art, and excerpt from the surrounding post. - if ( ! is_singular() ) { + if ( empty( $attributes['mediaUrl'] ) ) { return ''; } - if ( empty( $attributes['mediaUrl'] ) ) { + // 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 ''; } @@ -86,12 +99,13 @@ function render_block( $attributes, $content ) { $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 surrounding post. - $title = get_the_title(); - $excerpt = get_the_excerpt(); - $author_name = get_the_author(); - $publish_date = get_the_date( 'c' ); - $image_url = $show_poster ? (string) get_the_post_thumbnail_url( null, 'medium_large' ) : ''; + // Pull display content from the resolved post (block context or global loop). + $title = get_the_title( $post ); + $excerpt = get_the_excerpt( $post ); + $author_name = get_the_author_meta( 'display_name', $post->post_author ); + $publish_date_iso = get_the_date( 'c', $post ); + $publish_date = get_the_date( '', $post ); + $image_url = $show_poster ? (string) get_the_post_thumbnail_url( $post, 'medium_large' ) : ''; $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports(); $wrapper_style = ! empty( $wrapper_attributes['style'] ) ? $wrapper_attributes['style'] : ''; @@ -112,7 +126,7 @@ class=""

<?php echo esc_attr( $title ); ?> @@ -161,10 +175,10 @@ class="" 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..3834c5aaf8be 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,8 @@ /* global mejs */ -export const convertSecondsToTimeCode = mejs.Utils.secondsToTimeCode; -export const convertTimeCodeToSeconds = mejs.Utils.timeCodeToSeconds; +// 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 => mejs.Utils.secondsToTimeCode( seconds ); +export const convertTimeCodeToSeconds = timecode => mejs.Utils.timeCodeToSeconds( timecode ); From e692d146a67dffe76094f69436669e3547fe1a75 Mon Sep 17 00:00:00 2001 From: Rob Pugh Date: Wed, 6 May 2026 10:03:02 -0400 Subject: [PATCH 06/14] Podcast Episode: episode cover art override, drop excerpt/GUID UI Cover art now falls back show -> episode override (Apple/Spotify hierarchy). Featured image is no longer reused, so themes don't render the same image twice. Show notes hint replaces the excerpt summary on the player card so authors write notes once, in post content. GUID UI dropped - RSS layer derives it from the permalink. --- .../blocks/podcast-episode/block.json | 7 +- .../extensions/blocks/podcast-episode/edit.js | 101 +++++++++++++----- .../blocks/podcast-episode/editor.scss | 20 ++++ .../podcast-episode/podcast-episode.php | 40 +++++-- 4 files changed, 127 insertions(+), 41 deletions(-) diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json index 570d60261ea8..46694f731a70 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/block.json @@ -54,9 +54,6 @@ "type": "boolean", "default": false }, - "guid": { - "type": "string" - }, "duration": { "type": "string", "default": "" @@ -93,6 +90,10 @@ "showPoster": { "type": "boolean", "default": true + }, + "coverArt": { + "type": "object", + "default": {} } }, "example": { diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js index 2366701e265e..a44e918d7909 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -4,6 +4,8 @@ import { InspectorControls, MediaPlaceholder, MediaReplaceFlow, + MediaUpload, + MediaUploadCheck, useBlockProps, } from '@wordpress/block-editor'; import { @@ -113,7 +115,6 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context seasonNumber, episodeType, explicit, - guid, duration, transcriptUrl, transcriptType, @@ -123,29 +124,27 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context licenseUrl, people, showPoster, + coverArt, } = validated; const { postId, postType } = context || {}; const [ postTitle ] = useEntityProp( 'postType', postType, 'title', postId ); - const [ postExcerpt ] = useEntityProp( 'postType', postType, 'excerpt', postId ); - const [ featuredId ] = useEntityProp( 'postType', postType, 'featured_media', postId ); const [ postDate ] = useEntityProp( 'postType', postType, 'date', postId ); const [ authorId ] = useEntityProp( 'postType', postType, 'author', postId ); - const { thumbnailUrl, postAuthor } = useSelect( + const postAuthor = useSelect( select => { - const core = select( coreStore ); - const media = featuredId ? core.getMedia( featuredId ) : null; - const author = authorId ? core.getUser( authorId ) : null; - return { - thumbnailUrl: media?.source_url || '', - postAuthor: author?.name || '', - }; + const author = authorId ? select( coreStore ).getUser( authorId ) : null; + return author?.name || ''; }, - [ featuredId, authorId ] + [ authorId ] ); + const showCoverUrl = + ( typeof window !== 'undefined' && window.jetpackPodcastEpisodeData?.showCoverUrl ) || ''; + const coverArtUrl = coverArt?.url || showCoverUrl; + const blockProps = useBlockProps(); const [ uploadError, setUploadError ] = useState( null ); @@ -214,7 +213,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context icon={ microphone } label={ __( 'Podcast Episode', 'jetpack' ) } instructions={ __( - 'This block reads the title, cover art, excerpt, and author from the post it lives in. Drop it inside a podcast post or singular template.', + 'This block reads the title, author, and date from the post it lives in. Drop it inside a podcast post or singular template.', 'jetpack' ) } /> @@ -308,11 +307,65 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context /> 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' + ) } +

+
+ ) } @@ -324,14 +377,6 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context __nextHasNoMarginBottom __next40pxDefaultSize /> - setAttributes( { guid: value } ) } - __nextHasNoMarginBottom - __next40pxDefaultSize - /> @@ -397,9 +442,9 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context
- { showPoster && thumbnailUrl && ( + { showPoster && coverArtUrl && (
- +
) }
@@ -464,7 +509,7 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context src={ mediaUrl } controls preload="metadata" - poster={ showPoster ? thumbnailUrl : undefined } + poster={ showPoster ? coverArtUrl : undefined } data-mime={ mediaMimeType || undefined } /> ) : ( @@ -472,9 +517,9 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context ) }
- { postExcerpt && ( -

{ postExcerpt }

- ) } +

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

diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss index 6a76fd68f954..f17649e92686 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/editor.scss @@ -9,4 +9,24 @@ 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/podcast-episode.php b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php index 6d1c8e449e83..bffd8a5c86cd 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/podcast-episode.php @@ -39,11 +39,27 @@ function register_block() { } 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 ) ); + 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, cover art, excerpt, and author from the surrounding post — - * the post is the episode. The block stores only audio + episode metadata. + * 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). @@ -100,12 +116,20 @@ function render_block( $attributes, $content, $block = null ) { $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 ); - $excerpt = get_the_excerpt( $post ); - $author_name = get_the_author_meta( 'display_name', $post->post_author ); + $title = get_the_title( $post ); + $author_name = get_the_author_meta( 'display_name', $post->post_author ); $publish_date_iso = get_the_date( 'c', $post ); $publish_date = get_the_date( '', $post ); - $image_url = $show_poster ? (string) get_the_post_thumbnail_url( $post, 'medium_large' ) : ''; + + // Cover art: episode-specific override → show-level podcasting_image option → none. + $image_url = ''; + if ( $show_poster ) { + if ( ! empty( $attributes['coverArt']['url'] ) ) { + $image_url = esc_url_raw( $attributes['coverArt']['url'] ); + } else { + $image_url = (string) get_option( 'podcasting_image', '' ); + } + } $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports(); $wrapper_style = ! empty( $wrapper_attributes['style'] ) ? $wrapper_attributes['style'] : ''; @@ -219,10 +243,6 @@ class="jetpack-podcast-episode__audio"
- -

- -
    Date: Wed, 6 May 2026 10:27:46 -0400 Subject: [PATCH 07/14] Podcast Episode: fix CI lint/phpcs/phan/build errors - Prettier: collapse multi-line help, VisualLabel children, ternary args. - i18n: move translator comments inside sprintf so the rule reads them. - i18n: split __() ternary so the extractor sees literal msgids. - PHPCS: pass JSON encoding flags to wp_json_encode. - Phan: cast post_author to int for get_the_author_meta. --- .../extensions/blocks/podcast-episode/edit.js | 36 +++++++++---------- .../podcast-episode/podcast-episode.php | 7 ++-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js index a44e918d7909..9b8b896dc000 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-episode/edit.js @@ -307,26 +307,19 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context /> setAttributes( { showPoster: value } ) } __nextHasNoMarginBottom /> { showPoster && ( - - { __( 'Cover art', 'jetpack' ) } - + { __( 'Cover art', 'jetpack' ) } setAttributes( { - coverArt: media?.url - ? { id: media.id, url: media.url } - : {}, + coverArt: media?.url ? { id: media.id, url: media.url } : {}, } ) } allowedTypes={ [ 'image' ] } @@ -341,9 +334,8 @@ export default function PodcastEpisodeEdit( { attributes, setAttributes, context /> ) } { coverArt?.url && (