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 );
+ }
+}