diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3b7f282dcfc6..55f9d050a604 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3545,6 +3545,107 @@ importers:
specifier: 6.0.1
version: 6.0.1(webpack@5.105.2)
+ projects/packages/podcast:
+ dependencies:
+ '@automattic/jetpack-components':
+ specifier: workspace:*
+ version: link:../../js-packages/components
+ '@automattic/jetpack-script-data':
+ specifier: workspace:*
+ version: link:../../js-packages/script-data
+ '@tanstack/react-query':
+ specifier: 5.96.1
+ version: 5.96.1(react@18.3.1)
+ '@wordpress/api-fetch':
+ specifier: 7.44.0
+ version: 7.44.0
+ '@wordpress/components':
+ specifier: 32.6.0
+ version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/compose':
+ specifier: 7.44.0
+ version: 7.44.0(react@18.3.1)
+ '@wordpress/data':
+ specifier: 10.44.0
+ version: 10.44.0(react@18.3.1)
+ '@wordpress/dataviews':
+ specifier: 14.1.0
+ version: 14.1.0(@types/react@18.3.28)(react@18.3.1)
+ '@wordpress/element':
+ specifier: 6.44.0
+ version: 6.44.0
+ '@wordpress/html-entities':
+ specifier: 4.44.0
+ version: 4.44.0
+ '@wordpress/i18n':
+ specifier: 6.17.0
+ version: 6.17.0
+ '@wordpress/icons':
+ specifier: 12.2.0
+ version: 12.2.0(react@18.3.1)
+ '@wordpress/media-utils':
+ specifier: 5.44.0
+ version: 5.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/notices':
+ specifier: 5.44.0
+ version: 5.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/ui':
+ specifier: 0.11.0
+ version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/url':
+ specifier: 4.44.0
+ version: 4.44.0
+ devDependencies:
+ '@automattic/babel-plugin-replace-textdomain':
+ specifier: workspace:*
+ version: link:../../js-packages/babel-plugin-replace-textdomain
+ '@automattic/jetpack-webpack-config':
+ specifier: workspace:*
+ version: link:../../js-packages/webpack-config
+ '@babel/core':
+ specifier: 7.29.0
+ version: 7.29.0
+ '@babel/runtime':
+ specifier: 7.29.2
+ version: 7.29.2
+ '@types/react':
+ specifier: 18.3.28
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: 18.3.7
+ version: 18.3.7(@types/react@18.3.28)
+ '@typescript/native-preview':
+ specifier: 7.0.0-dev.20260225.1
+ version: 7.0.0-dev.20260225.1
+ '@wordpress/browserslist-config':
+ specifier: 6.44.0
+ version: 6.44.0
+ jest:
+ specifier: 30.3.0
+ version: 30.3.0
+ postcss:
+ specifier: 8.5.10
+ version: 8.5.10
+ sass-embedded:
+ specifier: 1.97.3
+ version: 1.97.3
+ sass-loader:
+ specifier: 16.0.5
+ version: 16.0.5(sass-embedded@1.97.3)(webpack@5.105.2)
+ webpack:
+ specifier: 5.105.2
+ version: 5.105.2(webpack-cli@6.0.1)
+ webpack-cli:
+ specifier: 6.0.1
+ version: 6.0.1(webpack@5.105.2)
+ optionalDependencies:
+ react:
+ specifier: 18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: 18.3.1
+ version: 18.3.1(react@18.3.1)
+
projects/packages/post-list:
dependencies:
'@wordpress/i18n':
@@ -9506,6 +9607,9 @@ packages:
'@tanstack/query-core@5.90.8':
resolution: {integrity: sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ==}
+ '@tanstack/query-core@5.96.1':
+ resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==}
+
'@tanstack/query-devtools@5.90.1':
resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==}
@@ -9520,6 +9624,11 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-query@5.96.1':
+ resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tanstack/react-router@1.167.1':
resolution: {integrity: sha512-hjBvkqXAQBligGekD6wYidl0jlXYwigYMcVkBQz3kXdWQ9fP/Ifbwu5w8zKnlRbuFHF90k1vY9UHjaWdsY3ILA==}
engines: {node: '>=20.19'}
@@ -18135,7 +18244,7 @@ snapshots:
'@automattic/number-formatters': 1.1.5
'@automattic/oauth-token': 1.0.1
'@automattic/shopping-cart': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@tanstack/react-query': 5.90.8(react@18.3.1)
+ '@tanstack/react-query': 5.96.1(react@18.3.1)
'@wordpress/api-fetch': 7.44.0
'@wordpress/data': 10.44.0(react@18.3.1)
'@wordpress/data-controls': 4.44.0(react@18.3.1)
@@ -18206,7 +18315,7 @@ snapshots:
'@automattic/i18n-utils': 1.2.3
'@automattic/typography': 1.0.0
'@automattic/viewport': 1.1.0
- '@tanstack/react-query': 5.90.8(react@18.3.1)
+ '@tanstack/react-query': 5.96.1(react@18.3.1)
'@wordpress/base-styles': 6.20.0
'@wordpress/components': 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@wordpress/data': 10.44.0(react@18.3.1)
@@ -22010,6 +22119,8 @@ snapshots:
'@tanstack/query-core@5.90.8': {}
+ '@tanstack/query-core@5.96.1': {}
+
'@tanstack/query-devtools@5.90.1': {}
'@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.8(react@18.3.1))(react@18.3.1)':
@@ -22023,6 +22134,11 @@ snapshots:
'@tanstack/query-core': 5.90.8
react: 18.3.1
+ '@tanstack/react-query@5.96.1(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-core': 5.96.1
+ react: 18.3.1
+
'@tanstack/react-router@1.167.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/history': 1.161.5
diff --git a/projects/packages/jetpack-mu-wpcom/changelog/wire-podcast-admin-page b/projects/packages/jetpack-mu-wpcom/changelog/wire-podcast-admin-page
new file mode 100644
index 000000000000..ca348488f541
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/changelog/wire-podcast-admin-page
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Jetpack > Podcast: replace the Calypso redirect with the in-admin Podcast SPA when the Jetpack Podcast package is loaded.
diff --git a/projects/packages/jetpack-mu-wpcom/composer.json b/projects/packages/jetpack-mu-wpcom/composer.json
index 832f38ddb5ca..6da6a23e1ef0 100644
--- a/projects/packages/jetpack-mu-wpcom/composer.json
+++ b/projects/packages/jetpack-mu-wpcom/composer.json
@@ -17,6 +17,7 @@
"automattic/jetpack-google-analytics": "@dev",
"automattic/jetpack-masterbar": "@dev",
"automattic/jetpack-newsletter": "@dev",
+ "automattic/jetpack-podcast": "@dev",
"automattic/jetpack-redirect": "@dev",
"automattic/jetpack-rtc": "@dev",
"automattic/jetpack-stats-admin": "@dev",
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php
index f299e5fdf496..61f2d901cd6b 100644
--- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php
@@ -401,15 +401,8 @@ function () {
$subscribers_dashboard->add_wp_admin_submenu();
}
- // Jetpack > Podcasting
- add_submenu_page(
- 'jetpack',
- __( 'Podcasting', 'jetpack-mu-wpcom' ),
- __( 'Podcasting', 'jetpack-mu-wpcom' ),
- 'manage_options',
- 'https://wordpress.com/settings/podcasting/' . $domain,
- null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
- );
+ // Jetpack > Podcast
+ \Automattic\Jetpack\Podcast\Settings::add_wp_admin_submenu();
if ( $is_simple_site ) {
// Jetpack > Newsletter.
@@ -459,6 +452,7 @@ function () {
'search',
'subscribers',
'newsletter',
+ 'jetpack-podcast',
'podcasting',
'traffic',
'jetpack#/settings',
diff --git a/projects/packages/podcast/.gitattributes b/projects/packages/podcast/.gitattributes
new file mode 100644
index 000000000000..841674d19825
--- /dev/null
+++ b/projects/packages/podcast/.gitattributes
@@ -0,0 +1,21 @@
+# Files not needed to be distributed in the package.
+.gitattributes export-ignore
+.github/ export-ignore
+package.json export-ignore
+
+# Files to include in the mirror repo, but excluded via gitignore
+# Remember to end all directories with `/**` to properly tag every file.
+/build/** production-include
+
+# Files to exclude from the mirror repo, but included in the monorepo.
+# Remember to end all directories with `/**` to properly tag every file.
+.gitignore production-exclude
+changelog/** production-exclude
+.phpcs.dir.xml production-exclude
+tests/** production-exclude
+.phpcsignore production-exclude
+tsconfig.json production-exclude
+global.d.ts production-exclude
+src/**/*.scss production-exclude
+src/**/*.tsx production-exclude
+src/**/*.ts production-exclude
diff --git a/projects/packages/podcast/.gitignore b/projects/packages/podcast/.gitignore
new file mode 100644
index 000000000000..cf368200ac9d
--- /dev/null
+++ b/projects/packages/podcast/.gitignore
@@ -0,0 +1,5 @@
+vendor/
+node_modules/
+.cache/
+build/
+composer.lock
diff --git a/projects/packages/podcast/.phan/baseline.php b/projects/packages/podcast/.phan/baseline.php
new file mode 100644
index 000000000000..3df50068147a
--- /dev/null
+++ b/projects/packages/podcast/.phan/baseline.php
@@ -0,0 +1,17 @@
+ [
+ ],
+ // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed.
+ // (directory_suppressions will currently be ignored by subsequent calls to --save-baseline, but may be preserved in future Phan releases)
+];
diff --git a/projects/packages/podcast/.phan/config.php b/projects/packages/podcast/.phan/config.php
new file mode 100644
index 000000000000..86a515290b91
--- /dev/null
+++ b/projects/packages/podcast/.phan/config.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/packages/podcast/CHANGELOG.md b/projects/packages/podcast/CHANGELOG.md
new file mode 100644
index 000000000000..03a962f457f6
--- /dev/null
+++ b/projects/packages/podcast/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
diff --git a/projects/packages/podcast/babel.config.js b/projects/packages/podcast/babel.config.js
new file mode 100644
index 000000000000..aa21158cf844
--- /dev/null
+++ b/projects/packages/podcast/babel.config.js
@@ -0,0 +1,10 @@
+const config = {
+ presets: [
+ [
+ '@automattic/jetpack-webpack-config/babel/preset',
+ { pluginReplaceTextdomain: { textdomain: 'jetpack-podcast' } },
+ ],
+ ],
+};
+
+export default config;
diff --git a/projects/packages/podcast/changelog/.gitkeep b/projects/packages/podcast/changelog/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/projects/packages/podcast/changelog/add-podcast-package b/projects/packages/podcast/changelog/add-podcast-package
new file mode 100644
index 000000000000..9c82a1b6cccb
--- /dev/null
+++ b/projects/packages/podcast/changelog/add-podcast-package
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Initial release of the Jetpack Podcast package: wp-admin SPA, RSS feed customization, and site-settings REST integration. Available on Simple and Atomic sites only.
diff --git a/projects/packages/podcast/composer.json b/projects/packages/podcast/composer.json
new file mode 100644
index 000000000000..e3bc8cb6a7ff
--- /dev/null
+++ b/projects/packages/podcast/composer.json
@@ -0,0 +1,63 @@
+{
+ "name": "automattic/jetpack-podcast",
+ "description": "Jetpack Podcast functionality (Simple and Atomic only).",
+ "type": "jetpack-library",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "php": ">=7.2",
+ "automattic/jetpack-admin-ui": "@dev",
+ "automattic/jetpack-assets": "@dev",
+ "automattic/jetpack-status": "@dev"
+ },
+ "require-dev": {
+ "yoast/phpunit-polyfills": "^4.0.0",
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev"
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "build-development": "pnpm run build",
+ "build-production": "pnpm run build-production",
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ],
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "typecheck": "pnpm run typecheck"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../packages/*",
+ "options": {
+ "monorepo": true
+ }
+ }
+ ],
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "autorelease": true,
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-podcast",
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "textdomain": "jetpack-podcast",
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-podcast.php"
+ },
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}"
+ }
+ }
+}
diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json
new file mode 100644
index 000000000000..565ff9618215
--- /dev/null
+++ b/projects/packages/podcast/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@automattic/jetpack-podcast",
+ "version": "0.1.0-alpha",
+ "private": true,
+ "description": "Jetpack Podcast functionality (Simple and Atomic only).",
+ "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/podcast/#readme",
+ "bugs": {
+ "url": "https://github.com/Automattic/jetpack/labels/[Package] Podcast"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Automattic/jetpack.git",
+ "directory": "projects/packages/podcast"
+ },
+ "license": "GPL-2.0-or-later",
+ "author": "Automattic",
+ "type": "module",
+ "scripts": {
+ "build": "pnpm run clean && pnpm run build-client",
+ "build-client": "pnpm webpack --config webpack.config.js",
+ "build-js": "webpack --config webpack.config.js",
+ "build-production": "pnpm run clean && pnpm run build-production-js && pnpm run validate",
+ "build-production-js": "NODE_ENV=production BABEL_ENV=production pnpm run build-js",
+ "clean": "rm -rf build/",
+ "test": "jest --config=tests/jest.config.js --passWithNoTests",
+ "typecheck": "tsgo --noEmit",
+ "validate": "pnpm exec validate-es build/",
+ "watch": "pnpm run build && pnpm webpack watch"
+ },
+ "browserslist": [
+ "extends @wordpress/browserslist-config"
+ ],
+ "dependencies": {
+ "@automattic/jetpack-components": "workspace:*",
+ "@automattic/jetpack-script-data": "workspace:*",
+ "@tanstack/react-query": "5.96.1",
+ "@wordpress/api-fetch": "7.44.0",
+ "@wordpress/components": "32.6.0",
+ "@wordpress/compose": "7.44.0",
+ "@wordpress/data": "10.44.0",
+ "@wordpress/dataviews": "14.1.0",
+ "@wordpress/element": "6.44.0",
+ "@wordpress/html-entities": "4.44.0",
+ "@wordpress/i18n": "6.17.0",
+ "@wordpress/icons": "12.2.0",
+ "@wordpress/media-utils": "5.44.0",
+ "@wordpress/notices": "5.44.0",
+ "@wordpress/ui": "0.11.0",
+ "@wordpress/url": "4.44.0"
+ },
+ "devDependencies": {
+ "@automattic/babel-plugin-replace-textdomain": "workspace:*",
+ "@automattic/jetpack-webpack-config": "workspace:*",
+ "@babel/core": "7.29.0",
+ "@babel/runtime": "7.29.2",
+ "@types/react": "18.3.28",
+ "@types/react-dom": "18.3.7",
+ "@typescript/native-preview": "7.0.0-dev.20260225.1",
+ "@wordpress/browserslist-config": "6.44.0",
+ "jest": "30.3.0",
+ "postcss": "8.5.10",
+ "sass-embedded": "1.97.3",
+ "sass-loader": "16.0.5",
+ "webpack": "5.105.2",
+ "webpack-cli": "6.0.1"
+ },
+ "optionalDependencies": {
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ }
+}
diff --git a/projects/packages/podcast/phpunit.11.xml.dist b/projects/packages/podcast/phpunit.11.xml.dist
new file mode 120000
index 000000000000..707bde67863c
--- /dev/null
+++ b/projects/packages/podcast/phpunit.11.xml.dist
@@ -0,0 +1 @@
+phpunit.9.xml.dist
\ No newline at end of file
diff --git a/projects/packages/podcast/phpunit.12.xml.dist b/projects/packages/podcast/phpunit.12.xml.dist
new file mode 120000
index 000000000000..9fdb7a2c745c
--- /dev/null
+++ b/projects/packages/podcast/phpunit.12.xml.dist
@@ -0,0 +1 @@
+phpunit.11.xml.dist
\ No newline at end of file
diff --git a/projects/packages/podcast/phpunit.8.xml.dist b/projects/packages/podcast/phpunit.8.xml.dist
new file mode 120000
index 000000000000..707bde67863c
--- /dev/null
+++ b/projects/packages/podcast/phpunit.8.xml.dist
@@ -0,0 +1 @@
+phpunit.9.xml.dist
\ No newline at end of file
diff --git a/projects/packages/podcast/phpunit.9.xml.dist b/projects/packages/podcast/phpunit.9.xml.dist
new file mode 100644
index 000000000000..3965963c485e
--- /dev/null
+++ b/projects/packages/podcast/phpunit.9.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests/php
+
+
+
diff --git a/projects/packages/podcast/src/class-podcast.php b/projects/packages/podcast/src/class-podcast.php
new file mode 100644
index 000000000000..b5f195475494
--- /dev/null
+++ b/projects/packages/podcast/src/class-podcast.php
@@ -0,0 +1,155 @@
+is_wpcom_simple() && ! $host->is_woa_site() ) {
+ return;
+ }
+
+ $legacy_active = class_exists( 'Automattic_Podcasting', false );
+
+ // `register_setting()` always runs — it's the only path that exposes
+ // `podcasting_*` keys via `/wp/v2/settings` on Atomic, and the legacy
+ // wpcom mu-plugin / at-pressable-podcasting bridge don't register them
+ // there. Skipping it would leave the SPA with no way to read or write
+ // settings on Atomic. The wpcom-only `site_settings_endpoint_get` /
+ // `rest_api_update_site_settings` filters are a different story —
+ // those `do` overlap with the legacy code, so we skip them when the
+ // legacy code is loaded.
+ Settings_REST::init( ! $legacy_active );
+
+ // Admin page registration is only relevant in wp-admin contexts. Always
+ // registered (even alongside legacy) so the new SPA is the canonical
+ // entry point during the migration.
+ if ( is_admin() ) {
+ Settings::init();
+ }
+
+ // Feed customization is wired only when the podcast feed is being
+ // served, and only when we own the feed (no legacy code present).
+ // The actual `rss2_*` hook plumbing (and the matching `remove_action`
+ // calls) lives in Customize_Feed::init() so it's only registered when
+ // a feed request is in flight, not on every page load.
+ if ( ! $legacy_active && self::is_enabled() ) {
+ add_action( 'after_setup_theme', array( __CLASS__, 'add_post_thumbnail_support' ), 20 );
+
+ if ( ! is_admin() ) {
+ add_action( 'wp', array( __CLASS__, 'maybe_load_feed_customization' ) );
+ }
+ }
+ }
+
+ /**
+ * Load feed customization only when the podcast category feed is requested.
+ *
+ * Also runs the podcatcher detector here — same gate (single feed
+ * request), guaranteed to run before the response goes out, and cheap
+ * enough that piggybacking is fine.
+ */
+ public static function maybe_load_feed_customization() {
+ if ( is_feed() && is_category( self::get_category_id() ) ) {
+ Feed_Detection::detect_and_record();
+ Customize_Feed::init();
+ }
+ }
+
+ /**
+ * Episode-level feed images rely on post thumbnails.
+ */
+ public static function add_post_thumbnail_support() {
+ add_theme_support( 'post-thumbnails' );
+ }
+
+ /**
+ * Resolve the configured podcast category ID, falling back to the legacy slug option.
+ *
+ * @return int|false
+ */
+ public static function get_category_id() {
+ $cat_id = get_option( 'podcasting_category_id', false );
+
+ if ( false !== $cat_id ) {
+ $category = get_category( $cat_id );
+ if ( ! $category || ! isset( $category->term_id ) ) {
+ return false;
+ }
+ return (int) $category->term_id;
+ }
+
+ $archive_slug = get_option( 'podcasting_archive', false );
+ if ( false === $archive_slug ) {
+ return false;
+ }
+
+ $category = get_term_by( 'slug', $archive_slug, 'category' );
+ if ( ! $category || ! isset( $category->term_id ) ) {
+ return false;
+ }
+
+ return (int) $category->term_id;
+ }
+
+ /**
+ * Podcast is enabled when a category has been chosen.
+ *
+ * @return bool
+ */
+ public static function is_enabled() {
+ return (bool) self::get_category_id();
+ }
+
+ /**
+ * Resolve the podcast cover image URL, preferring an attachment if one is set.
+ *
+ * @return string
+ */
+ public static function get_image_url() {
+ $image_id = get_option( 'podcasting_image_id', false );
+ if ( $image_id && is_numeric( $image_id ) && wp_attachment_is_image( $image_id ) ) {
+ return (string) wp_get_attachment_url( $image_id );
+ }
+ return (string) get_option( 'podcasting_image', '' );
+ }
+}
diff --git a/projects/packages/podcast/src/class-settings.php b/projects/packages/podcast/src/class-settings.php
new file mode 100644
index 000000000000..ceecb24c003a
--- /dev/null
+++ b/projects/packages/podcast/src/class-settings.php
@@ -0,0 +1,138 @@
+ Podcast wp-admin screen.
+ *
+ * On Simple and Atomic the canonical entry point is `wpcom-admin-menu.php`
+ * (in the `jetpack-mu-wpcom` package), which calls `add_wp_admin_submenu()`
+ * at priority 999999 — late enough that the Jetpack parent menu is already
+ * registered. We do not register our own `admin_menu` hook here; doing so on
+ * Atomic would race with the wpcom-admin-menu callback and duplicate the
+ * "Podcasting" item that used to redirect to Calypso.
+ */
+class Settings {
+
+ const MENU_SLUG = 'jetpack-podcast';
+
+ /**
+ * Whether the admin-init hooks have been wired.
+ *
+ * @var bool
+ */
+ private static $admin_init_wired = false;
+
+ /**
+ * Init Podcast Settings.
+ *
+ * Currently a no-op kept for symmetry with the rest of the package — the
+ * actual menu registration happens via `add_wp_admin_submenu()`, called
+ * by `wpcom-admin-menu.php`.
+ */
+ public static function init() {
+ // Intentionally empty: see class docblock.
+ }
+
+ /**
+ * Register the Podcast submenu directly under the Jetpack menu.
+ *
+ * Called from wpcom-admin-menu.php at priority 999999 (Simple + Atomic)
+ * once the Jetpack menu is in place. The host gate happens earlier in
+ * `Podcast::init()` so by the time this runs we know we're on a host we
+ * support.
+ */
+ public static function add_wp_admin_submenu() {
+ $page_suffix = add_submenu_page(
+ 'jetpack',
+ /** "Podcast" is a product name, do not translate. */
+ 'Podcast',
+ 'Podcast',
+ 'manage_options',
+ self::MENU_SLUG,
+ array( __CLASS__, 'render' )
+ );
+
+ if ( $page_suffix && ! self::$admin_init_wired ) {
+ self::$admin_init_wired = true;
+ add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
+ }
+ }
+
+ /**
+ * Admin init actions. Triggered only when the Podcast page is being loaded.
+ */
+ public static function admin_init() {
+ add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
+ add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_admin_scripts' ) );
+ }
+
+ /**
+ * Inject podcast-specific data into the global JetpackScriptData object.
+ *
+ * @param array $data Existing script data.
+ * @return array
+ */
+ public static function add_script_data( $data ) {
+ $current_user = wp_get_current_user();
+ $host = new Host();
+ $blog_id = (int) $host->get_wpcom_site_id();
+ $category_id = Podcast::get_category_id();
+ $feed_url = $category_id ? get_term_feed_link( $category_id, 'category', 'rss2' ) : '';
+
+ $data['site']['wpcom']['blog_id'] = $blog_id;
+
+ $data['podcast'] = array(
+ 'categoryId' => $category_id ? (int) $category_id : 0,
+ 'feedUrl' => $feed_url ? $feed_url : '',
+ 'siteUrl' => get_site_url(),
+ 'adminUrl' => admin_url(),
+ 'editPostUrlBase' => admin_url( 'post.php?action=edit&post=' ),
+ 'newPostUrl' => admin_url( 'post-new.php' ),
+ 'mediaLibraryUrl' => admin_url( 'upload.php' ),
+ 'userEmail' => $current_user->user_email,
+ 'dateFormat' => (string) get_option( 'date_format', 'F j, Y' ),
+ );
+
+ return $data;
+ }
+
+ /**
+ * Enqueue the podcast SPA bundle.
+ *
+ * The asset.php manifest emitted by webpack already declares every
+ * `@wordpress/*` dependency our bundle pulls in, so the only manual entry
+ * we add here is `jetpack-script-data` (a Jetpack-specific dep webpack
+ * doesn't know to extract).
+ */
+ public static function load_admin_scripts() {
+ Assets::register_script(
+ 'jetpack-podcast',
+ '../build/podcast.js',
+ __FILE__,
+ array(
+ 'in_footer' => true,
+ 'textdomain' => 'jetpack-podcast',
+ 'enqueue' => true,
+ 'dependencies' => array( 'jetpack-script-data' ),
+ )
+ );
+ }
+
+ /**
+ * Render the Podcast SPA mount point.
+ */
+ public static function render() {
+ ?>
+
+ = [
+ 'podcasting_category_id',
+ 'podcasting_title',
+ 'podcasting_talent_name',
+ 'podcasting_summary',
+ 'podcasting_copyright',
+ 'podcasting_explicit',
+ 'podcasting_image',
+ 'podcasting_image_id',
+ 'podcasting_category_1',
+ 'podcasting_category_2',
+ 'podcasting_category_3',
+ 'podcasting_email',
+ 'podcasting_show_urls',
+];
+
+// Keep this in sync with `PodcatcherId` in types.ts and `SHOW_URL_HOSTS`
+// in src/rest/class-settings-rest.php. Defines the canonical key order
+// and lets us pad missing keys with empty strings server-side or client-side.
+const PODCATCHER_IDS: readonly PodcatcherId[] = [
+ 'pocketcasts',
+ 'apple',
+ 'spotify',
+ 'youtube',
+ 'amazon',
+ 'podcastindex',
+] as const;
+
+const normalizeShowUrls = ( raw: unknown ): PodcastShowUrls => {
+ const source = ( raw && typeof raw === 'object' ? raw : {} ) as Record< string, unknown >;
+ const out = {} as PodcastShowUrls;
+ for ( const id of PODCATCHER_IDS ) {
+ const value = source[ id ];
+ out[ id ] = typeof value === 'string' ? value : '';
+ }
+ return out;
+};
+
+const getBlogId = (): number => Number( getSiteData()?.wpcom?.blog_id ?? 0 );
+
+const pickPodcastFields = ( raw: Record< string, unknown > ): PodcastSettings => {
+ const numericKey = ( key: keyof PodcastSettings ) =>
+ key === 'podcasting_category_id' || key === 'podcasting_image_id';
+
+ const toString = ( value: unknown ): string => {
+ if ( typeof value === 'string' ) {
+ return value;
+ }
+ if ( value == null ) {
+ return '';
+ }
+ return String( value );
+ };
+
+ const out: Record< string, unknown > = {};
+ for ( const key of PODCAST_KEYS ) {
+ const value = raw[ key ];
+ if ( numericKey( key ) ) {
+ out[ key ] = typeof value === 'number' ? value : Number( value ?? 0 ) || 0;
+ } else if ( key === 'podcasting_explicit' ) {
+ out[ key ] = value === 'yes' || value === 'clean' ? value : 'no';
+ } else if ( key === 'podcasting_show_urls' ) {
+ out[ key ] = normalizeShowUrls( value );
+ } else {
+ out[ key ] = toString( value );
+ }
+ }
+ return out as unknown as PodcastSettings;
+};
+
+/**
+ * Fetch the podcasting_* options from the right host's settings endpoint.
+ *
+ * @return The current settings, with all PodcastSettings keys present.
+ */
+export async function fetchSettings(): Promise< PodcastSettings > {
+ const blogId = getBlogId();
+
+ if ( isSimpleSite() && blogId ) {
+ const result = ( await apiFetch( {
+ path: `/rest/v1.4/sites/${ blogId }/settings`,
+ method: 'GET',
+ } ) ) as { settings?: Record< string, unknown > };
+ return pickPodcastFields( ( result.settings || result ) as Record< string, unknown > );
+ }
+
+ const result = ( await apiFetch( {
+ path: '/wp/v2/settings',
+ method: 'GET',
+ } ) ) as Record< string, unknown >;
+ return pickPodcastFields( result );
+}
+
+/**
+ * Persist a partial settings update.
+ *
+ * @param updates - Subset of PodcastSettings to write.
+ * @return The merged settings as the server now sees them.
+ */
+export async function updateSettings( updates: PodcastSettingsUpdate ): Promise< PodcastSettings > {
+ const blogId = getBlogId();
+
+ if ( isSimpleSite() && blogId ) {
+ const result = ( await apiFetch( {
+ path: `/rest/v1.4/sites/${ blogId }/settings`,
+ method: 'POST',
+ data: updates,
+ } ) ) as { updated?: Record< string, unknown > };
+ return pickPodcastFields( ( result.updated || result ) as Record< string, unknown > );
+ }
+
+ const result = ( await apiFetch( {
+ path: '/wp/v2/settings',
+ method: 'POST',
+ data: updates,
+ } ) ) as Record< string, unknown >;
+ return pickPodcastFields( result );
+}
+
+/**
+ * Fetch every category term, paging through 100 at a time.
+ *
+ * @return All category terms on the site.
+ */
+export async function fetchCategories(): Promise< CategoryTerm[] > {
+ const blogId = getBlogId();
+
+ if ( isSimpleSite() && blogId ) {
+ const out: CategoryTerm[] = [];
+ let page = 1;
+
+ while ( true ) {
+ const result = ( await apiFetch( {
+ path: `/rest/v1.1/sites/${ blogId }/taxonomies/category/terms?page=${ page }&number=100`,
+ method: 'GET',
+ } ) ) as {
+ terms?: Array< { ID: number; name: string; slug: string } >;
+ found?: number;
+ };
+ const terms = result.terms || [];
+ out.push( ...terms.map( t => ( { id: t.ID, name: t.name, slug: t.slug } ) ) );
+ if ( out.length >= ( result.found || 0 ) || terms.length === 0 ) {
+ break;
+ }
+ page++;
+ }
+ return out;
+ }
+
+ const out: CategoryTerm[] = [];
+ let page = 1;
+
+ while ( true ) {
+ const response = ( await apiFetch( {
+ path: addQueryArgs( '/wp/v2/categories', { per_page: 100, page } ),
+ method: 'GET',
+ parse: false,
+ } ) ) as Response;
+ const data = ( await response.json() ) as Array< { id: number; name: string; slug: string } >;
+ out.push( ...data.map( t => ( { id: t.id, name: t.name, slug: t.slug } ) ) );
+ const totalPages = parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 );
+ if ( page >= totalPages || data.length === 0 ) {
+ break;
+ }
+ page++;
+ }
+ return out;
+}
+
+/**
+ * Create a new category term.
+ *
+ * @param name - Display name for the new category.
+ * @return The created term (id, name, slug).
+ */
+export async function createCategory( name: string ): Promise< CategoryTerm > {
+ const blogId = getBlogId();
+
+ if ( isSimpleSite() && blogId ) {
+ const result = ( await apiFetch( {
+ path: `/rest/v1.1/sites/${ blogId }/taxonomies/category/new`,
+ method: 'POST',
+ data: { name },
+ } ) ) as { ID: number; name: string; slug: string };
+ return { id: result.ID, name: result.name, slug: result.slug };
+ }
+
+ const result = ( await apiFetch( {
+ path: '/wp/v2/categories',
+ method: 'POST',
+ data: { name },
+ } ) ) as { id: number; name: string; slug: string };
+ return { id: result.id, name: result.name, slug: result.slug };
+}
+
+/**
+ * Fetch a page of posts in the podcast category.
+ *
+ * @param args - Pagination, sort, search, and status filter args.
+ * @return The posts for the requested page plus pagination metadata.
+ */
+export async function fetchEpisodes( args: EpisodesQueryArgs ): Promise< EpisodesPage > {
+ const {
+ categoryId,
+ page = 1,
+ perPage = 20,
+ orderBy = 'date',
+ order = 'desc',
+ search = '',
+ status = 'any',
+ } = args;
+
+ const query: Record< string, string | number > = {
+ categories: categoryId,
+ page,
+ per_page: perPage,
+ orderby: orderBy,
+ order,
+ _embed: 'wp:featuredmedia',
+ };
+ if ( search ) {
+ query.search = search;
+ }
+ if ( status ) {
+ query.status = status;
+ }
+
+ const response = ( await apiFetch( {
+ path: addQueryArgs( '/wp/v2/posts', query ),
+ method: 'GET',
+ parse: false,
+ } ) ) as Response;
+
+ const episodes = ( await response.json() ) as Episode[];
+ const total = parseInt( response.headers.get( 'X-WP-Total' ) || '0', 10 );
+ const totalPages = parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 );
+
+ return { episodes, total, totalPages };
+}
+
+/**
+ * Fetch per-episode plays + duration. Chunked to 50 IDs per request to match
+ * the wpcom endpoint's max page size.
+ *
+ * @param postIds - Episode post IDs to look up stats for.
+ * @return Stats for each post that had data; missing posts are omitted.
+ */
+export async function fetchEpisodeStats( postIds: number[] ): Promise< EpisodeStats[] > {
+ if ( postIds.length === 0 ) {
+ return [];
+ }
+
+ const blogId = getBlogId();
+ if ( ! blogId ) {
+ return [];
+ }
+
+ const out: EpisodeStats[] = [];
+ for ( let i = 0; i < postIds.length; i += 50 ) {
+ const chunk = postIds.slice( i, i + 50 );
+ const result = ( await apiFetch( {
+ path: addQueryArgs( `/wpcom/v2/sites/${ blogId }/podcast-stats/episode-totals`, {
+ post_ids: chunk.join( ',' ),
+ } ),
+ method: 'GET',
+ } ) ) as { episodes?: EpisodeStats[] } | EpisodeStats[];
+
+ if ( Array.isArray( result ) ) {
+ out.push( ...result );
+ } else if ( result.episodes ) {
+ out.push( ...result.episodes );
+ }
+ }
+ return out;
+}
diff --git a/projects/packages/podcast/src/dashboard/app.tsx b/projects/packages/podcast/src/dashboard/app.tsx
new file mode 100644
index 000000000000..9d508bdeee84
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/app.tsx
@@ -0,0 +1,190 @@
+/**
+ * Jetpack Podcast top-level app: AdminPage chrome + tab navigation.
+ */
+
+import { AdminPage, Container, Col, GlobalNotices } from '@automattic/jetpack-components';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { Spinner } from '@wordpress/components';
+import { lazy, Suspense, useCallback, useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Tabs } from '@wordpress/ui';
+import { usePodcastSettings } from './hooks/use-podcast-settings';
+import type { TabName } from './types';
+
+// Tabs are lazy-loaded so a visit to the page only pulls down the active tab's
+// bundle (and its hooks). DataViews + the Apple Podcasts topics list are the
+// two largest chunks; both stay out of the main bundle until needed.
+const WelcomeTab = lazy(
+ () => import( /* webpackChunkName: "podcast-welcome" */ './tabs/welcome' )
+);
+const SettingsTab = lazy(
+ () => import( /* webpackChunkName: "podcast-settings" */ './tabs/settings' )
+);
+const EpisodesTab = lazy(
+ () => import( /* webpackChunkName: "podcast-episodes" */ './tabs/episodes' )
+);
+const DistributionTab = lazy(
+ () => import( /* webpackChunkName: "podcast-distribution" */ './tabs/distribution' )
+);
+
+const TabFallback = () => (
+
+
+
+);
+
+const VALID_TABS: readonly TabName[] = [ 'welcome', 'settings', 'episodes', 'distribution' ];
+
+const isValidTab = ( value: string | null ): value is TabName =>
+ !! value && ( VALID_TABS as readonly string[] ).includes( value );
+
+/**
+ * Resolve the initial tab. Order of preference: URL hash (e.g. `#episodes`)
+ * so deep links and reloads stick; `welcome` if podcasting isn't set up yet;
+ * `settings` once a category is configured (matches Calypso's onboarding flow).
+ *
+ * @param isSetUp - Whether the site already has a podcast category configured.
+ * @return The tab to land on.
+ */
+const resolveInitialTab = ( isSetUp: boolean ): TabName => {
+ const hash = typeof window !== 'undefined' ? window.location.hash.replace( /^#/, '' ) : '';
+ if ( isValidTab( hash ) ) {
+ return hash;
+ }
+ return isSetUp ? 'settings' : 'welcome';
+};
+
+const queryClient = new QueryClient( {
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: 1,
+ staleTime: 30_000,
+ },
+ },
+} );
+
+const PodcastApp = () => {
+ const { data: settings, isLoading } = usePodcastSettings();
+ const isSetUp = !! settings && settings.podcasting_category_id > 0;
+
+ const [ activeTab, setActiveTab ] = useState< TabName >( () => resolveInitialTab( false ) );
+
+ // Settle the default tab once data resolves — `welcome` for new users,
+ // `settings` for sites already configured. Skipped if the URL hash already
+ // pinned a tab.
+ useEffect( () => {
+ if ( isLoading ) {
+ return;
+ }
+ const hash = window.location.hash.replace( /^#/, '' );
+ if ( isValidTab( hash ) ) {
+ return;
+ }
+ setActiveTab( isSetUp ? 'settings' : 'welcome' );
+ }, [ isLoading, isSetUp ] );
+
+ // Mirror the active tab to the URL hash for deep links and reload-stickiness.
+ useEffect( () => {
+ const next = `#${ activeTab }`;
+ if ( window.location.hash !== next ) {
+ window.history.replaceState( null, '', next );
+ }
+ }, [ activeTab ] );
+
+ // React to back/forward navigation between tabs.
+ useEffect( () => {
+ const onHashChange = () => {
+ const hash = window.location.hash.replace( /^#/, '' );
+ if ( isValidTab( hash ) ) {
+ setActiveTab( hash );
+ }
+ };
+ window.addEventListener( 'hashchange', onHashChange );
+ return () => window.removeEventListener( 'hashchange', onHashChange );
+ }, [] );
+
+ const handleTabChange = useCallback( ( value: string | null ) => {
+ if ( isValidTab( value ) ) {
+ setActiveTab( value );
+ }
+ }, [] );
+
+ const handleWelcomeGetStarted = useCallback( () => {
+ setActiveTab( 'settings' );
+ }, [] );
+
+ return (
+
+
+
+
+ { isLoading ? (
+
+
+
+ ) : (
+
+
+
+ { __( 'Welcome', 'jetpack-podcast' ) }
+ { __( 'Settings', 'jetpack-podcast' ) }
+
+ { __( 'Episodes', 'jetpack-podcast' ) }
+
+
+ { __( 'Distribution', 'jetpack-podcast' ) }
+
+
+
+
+
+ }>
+
+
+
+
+
+
+ }>
+
+
+
+
+
+
+ }>
+
+
+
+
+
+
+ }>
+
+
+
+
+
+ ) }
+
+
+
+ );
+};
+
+const App = () => (
+
+
+
+);
+
+export default App;
diff --git a/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx b/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx
new file mode 100644
index 000000000000..139df9cd2371
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx
@@ -0,0 +1,119 @@
+/**
+ * Cover image picker for the podcast Settings tab.
+ *
+ * Wraps `@wordpress/media-utils`'s `MediaUpload` (the standalone wp-admin
+ * version, not the block-editor one) so editors can pick an existing
+ * attachment or upload a new one. Apple Podcasts requires a square cover
+ * between 1400×1400 and 3000×3000 — we surface that as a soft warning rather
+ * than a hard block, since stock photo services often deliver close-but-not-
+ * exactly-square assets.
+ */
+
+import { Button } from '@wordpress/components';
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { MediaUpload } from '@wordpress/media-utils';
+
+interface CoverImageControlProps {
+ imageUrl: string;
+ imageId: number;
+ onSelect: ( imageId: number, imageUrl: string ) => void;
+ onRemove: () => void;
+ disabled?: boolean;
+}
+
+interface MediaUploadAttachment {
+ id: number;
+ url: string;
+ width?: number;
+ height?: number;
+}
+
+const COVER_MIN = 1400;
+const COVER_MAX = 3000;
+
+const validate = ( att: MediaUploadAttachment ): string | null => {
+ if ( ! att.width || ! att.height ) {
+ return null;
+ }
+ if ( att.width !== att.height ) {
+ return __(
+ 'Apple Podcasts requires a square image. Crop your image to a 1:1 ratio for the best results.',
+ 'jetpack-podcast'
+ );
+ }
+ if ( att.width < COVER_MIN || att.width > COVER_MAX ) {
+ return __(
+ 'For best results, use an image between 1400×1400 and 3000×3000 pixels.',
+ 'jetpack-podcast'
+ );
+ }
+ return null;
+};
+
+const CoverImageControl = ( {
+ imageUrl,
+ imageId,
+ onSelect,
+ onRemove,
+ disabled,
+}: CoverImageControlProps ) => {
+ const [ warning, setWarning ] = useState< string | null >( null );
+
+ const hasImage = !! imageUrl || imageId > 0;
+
+ // Pre-resolve the two button labels separately so the i18n-check-webpack-plugin
+ // validator sees two distinct __() calls in the bundled output. Inlining the
+ // ternary inside __() (or even between two __() calls in JSX) lets terser fold
+ // them into __(cond?'a':'b'), which the validator rejects.
+ const changeLabel = __( 'Change cover', 'jetpack-podcast' );
+ const setLabel = __( 'Set cover image', 'jetpack-podcast' );
+ const noImageLabel = __( 'No image set', 'jetpack-podcast' );
+ const triggerLabel = hasImage ? changeLabel : setLabel;
+
+ const handleSelect = useCallback(
+ ( att: MediaUploadAttachment ) => {
+ setWarning( validate( att ) );
+ onSelect( att.id, att.url );
+ },
+ [ onSelect ]
+ );
+
+ const renderTrigger = useCallback(
+ ( { open }: { open: () => void } ) => (
+
+ { triggerLabel }
+
+ ),
+ [ disabled, triggerLabel ]
+ );
+
+ return (
+
+
+ { imageUrl ? (
+
+ ) : (
+
{ noImageLabel }
+ ) }
+
+
+
+ { hasImage && (
+
+ { __( 'Remove', 'jetpack-podcast' ) }
+
+ ) }
+
+ { warning &&
{ warning }
}
+
+ );
+};
+
+export default CoverImageControl;
diff --git a/projects/packages/podcast/src/dashboard/components/submit-modal.tsx b/projects/packages/podcast/src/dashboard/components/submit-modal.tsx
new file mode 100644
index 000000000000..f106ad25d706
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/components/submit-modal.tsx
@@ -0,0 +1,363 @@
+/**
+ * Default 3-step "submit your feed" modal launched from the Distribution tab.
+ *
+ * Conforms to `PodcastAppModalProps` so an app's `Modal` field can swap in a
+ * custom flow without distribution.tsx caring. Apps that fit this pattern
+ * leave `Modal` unset and ride this default; ones with diverging flows (e.g.
+ * one-click API submission) ship their own component instead.
+ *
+ * The submitted-show URL is persisted on the `podcasting_show_urls` site
+ * setting. The host allowlist is enforced server-side; we mirror it here
+ * (PodcastApp.showHosts) so we can fail-fast on the client and surface an
+ * inline error rather than silently dropping the user's input.
+ */
+
+import {
+ Button,
+ ExternalLink,
+ Icon,
+ Modal,
+ Notice,
+ TextControl,
+ VisuallyHidden,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalText as Text,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useCopyToClipboard } from '@wordpress/compose';
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { check, external, link } from '@wordpress/icons';
+import { prependHTTPS } from '@wordpress/url';
+import { usePodcastSettings, useUpdatePodcastSettings } from '../hooks/use-podcast-settings';
+import type { PodcastAppModalProps } from '../podcast-apps';
+import type { FormEvent } from 'react';
+
+// Mirrors SHOW_URL_MAX_LENGTH in src/rest/class-settings-rest.php.
+const SHOW_URL_MAX_LENGTH = 2048;
+
+// `prependHTTPS` adds the scheme for bare hosts but leaves an existing
+// `http://` alone — the backend rejects non-https, so upgrade ourselves.
+const normalizeShowUrl = ( raw: string ): string =>
+ prependHTTPS( raw.trim() ).replace( /^http:\/\//i, 'https://' );
+
+// Mirrors the per-podcatcher allowlist + esc_url_raw + wp_http_validate_url
+// gauntlet the backend runs each save through. Empty input is rejected here
+// so the modal never silently deletes a stored entry by clearing the field.
+const isValidShowUrl = ( url: string, allowedHosts: readonly string[] ): boolean => {
+ if ( url === '' || url.length > SHOW_URL_MAX_LENGTH ) {
+ return false;
+ }
+ let parsed: URL;
+ try {
+ parsed = new URL( url );
+ } catch {
+ return false;
+ }
+ if ( parsed.protocol !== 'https:' ) {
+ return false;
+ }
+ const host = parsed.hostname.toLowerCase().replace( /^www\./, '' );
+ return allowedHosts.includes( host );
+};
+
+const SubmitModal = ( { app, feedUrl, onClose }: PodcastAppModalProps ) => {
+ const { data: settings } = usePodcastSettings();
+ const { mutate: saveSettings, isPending: isSaving } = useUpdatePodcastSettings();
+
+ const storedUrl = settings?.podcasting_show_urls?.[ app.id ] ?? '';
+ const [ draftUrl, setDraftUrl ] = useState( storedUrl );
+ const [ hasCopied, setHasCopied ] = useState( false );
+ const [ isEditing, setIsEditing ] = useState( false );
+ const [ saveError, setSaveError ] = useState< string | null >( null );
+ const inputContainerRef = useRef< HTMLDivElement >( null );
+ // `storedUrl` may be empty on mount if settings haven't hydrated yet;
+ // once it lands, mirror it into the draft. Flipped to true the moment
+ // the draft is touched (sync, typing, or Replace) so late hydration
+ // can never clobber input the user has already started.
+ const hasInitializedDraft = useRef( !! storedUrl );
+ // Set when Replace is clicked so the post-render effect knows to focus
+ // the now-mounted input. Don't trigger on every isEditing flip — typing
+ // also flips it, and stealing focus mid-keystroke is disruptive.
+ const shouldFocusInputRef = useRef( false );
+
+ useEffect( () => {
+ if ( ! hasInitializedDraft.current && storedUrl ) {
+ hasInitializedDraft.current = true;
+ setDraftUrl( storedUrl );
+ }
+ }, [ storedUrl ] );
+
+ useEffect( () => {
+ if ( ! shouldFocusInputRef.current || ! isEditing ) {
+ return;
+ }
+ shouldFocusInputRef.current = false;
+ const input = inputContainerRef.current?.querySelector( 'input' );
+ input?.focus();
+ input?.select();
+ }, [ isEditing ] );
+
+ const copyRef = useCopyToClipboard< HTMLButtonElement >( feedUrl, () => {
+ setHasCopied( true );
+ setTimeout( () => setHasCopied( false ), 2000 );
+ } );
+
+ const handleReplace = useCallback( () => {
+ hasInitializedDraft.current = true;
+ shouldFocusInputRef.current = true;
+ setDraftUrl( storedUrl );
+ setSaveError( null );
+ setIsEditing( true );
+ }, [ storedUrl ] );
+
+ const handleDraftChange = useCallback( ( value: string ) => {
+ hasInitializedDraft.current = true;
+ // Pin the form open so a late `storedUrl` hydration can't swap us
+ // back to the saved/read-only view mid-keystroke.
+ setIsEditing( true );
+ setDraftUrl( value );
+ setSaveError( null );
+ }, [] );
+
+ const handleDismissError = useCallback( () => setSaveError( null ), [] );
+
+ const normalizedDraft = normalizeShowUrl( draftUrl );
+ const isUnchanged = draftUrl === storedUrl;
+
+ const handleSave = useCallback(
+ ( event: FormEvent< HTMLFormElement > ) => {
+ event.preventDefault();
+ if ( ! isValidShowUrl( normalizedDraft, app.showHosts ) ) {
+ setSaveError(
+ sprintf(
+ /* translators: %s: podcast directory name (e.g. "Apple Podcasts"). */
+ __( 'Enter a valid %s URL.', 'jetpack-podcast' ),
+ app.name
+ )
+ );
+ return;
+ }
+ setSaveError( null );
+ saveSettings(
+ { podcasting_show_urls: { [ app.id ]: normalizedDraft } },
+ {
+ onSuccess: () => {
+ setIsEditing( false );
+ onClose();
+ },
+ onError: () => {
+ setSaveError(
+ sprintf(
+ /* translators: %s: podcast directory name. */
+ __( 'We couldn’t save your %s URL. Please try again.', 'jetpack-podcast' ),
+ app.name
+ )
+ );
+ },
+ }
+ );
+ },
+ [ normalizedDraft, app.showHosts, app.id, app.name, saveSettings, onClose ]
+ );
+
+ // Pre-resolve so the i18n-check-webpack-plugin validator sees two distinct
+ // __() calls in the bundled output instead of __(cond?'a':'b').
+ const copiedLabel = __( 'Copied!', 'jetpack-podcast' );
+ const copyLinkLabel = __( 'Copy link', 'jetpack-podcast' );
+
+ const titleText = sprintf(
+ /* translators: %s: podcast directory name (e.g. "Apple Podcasts"). */
+ __( 'Submit to %s', 'jetpack-podcast' ),
+ app.name
+ );
+
+ const showSavedReadOnly = !! storedUrl && ! isEditing;
+
+ return (
+
+
+
+
+ { __( 'Step 1: Copy your RSS feed URL', 'jetpack-podcast' ) }
+
+
+ { feedUrl
+ ? sprintf(
+ /* translators: %s: podcast directory name. */
+ __(
+ 'Click the button below to copy your RSS feed URL. %s will require this URL to list your podcast.',
+ 'jetpack-podcast'
+ ),
+ app.name
+ )
+ : __(
+ 'Set your podcast category in the Settings tab to generate your RSS feed URL.',
+ 'jetpack-podcast'
+ ) }
+
+ { feedUrl && (
+
+ { hasCopied ? copiedLabel : copyLinkLabel }
+
+ ) }
+
+
+
+
+ { sprintf(
+ /* translators: %s: podcast directory name. */
+ __( 'Step 2: Submit your podcast to %s', 'jetpack-podcast' ),
+ app.name
+ ) }
+
+
+ { sprintf(
+ /* translators: %s: podcast directory name. */
+ __(
+ 'Click the button below to visit %s and complete their sign up flow.',
+ 'jetpack-podcast'
+ ),
+ app.name
+ ) }
+
+ { app.learnMoreUrl && (
+
+
+ { __( 'Learn more', 'jetpack-podcast' ) }
+
+
+ ) }
+ { app.step2Extra }
+
+ { sprintf(
+ /* translators: %s: podcast directory name. */
+ __( 'Visit %s', 'jetpack-podcast' ),
+ app.name
+ ) }
+
+
+
+
+
+ { sprintf(
+ /* translators: %s: podcast directory name. */
+ __( 'Step 3: Enter your %s URL', 'jetpack-podcast' ),
+ app.name
+ ) }
+
+
+ { sprintf(
+ /* translators: %s: podcast directory name. */
+ __(
+ 'Paste your new %s URL into the field below and we’ll use it for your sharing buttons.',
+ 'jetpack-podcast'
+ ),
+ app.name
+ ) }
+
+ { showSavedReadOnly ? (
+
+
+
+ { __( 'Saved:', 'jetpack-podcast' ) }
+
+ { storedUrl }
+
+
+
+ { __( 'Replace', 'jetpack-podcast' ) }
+
+
+ ) : (
+
+ ) }
+
+
+
+ );
+};
+
+export default SubmitModal;
diff --git a/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts
new file mode 100644
index 000000000000..b71e5a64fcb7
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts
@@ -0,0 +1,39 @@
+/**
+ * TanStack Query hook for fetching all category terms.
+ */
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { createCategory, fetchCategories, type CategoryTerm } from '../api';
+
+const QUERY_KEY = [ 'jetpack-podcast', 'categories' ] as const;
+
+/**
+ * Read every category term on the site as a single cached query.
+ *
+ * @return Query result; `data` is the array of category terms once loaded.
+ */
+export function useCategoriesQuery() {
+ return useQuery< CategoryTerm[] >( {
+ queryKey: QUERY_KEY,
+ queryFn: fetchCategories,
+ staleTime: 60_000,
+ } );
+}
+
+/**
+ * Mutation that creates a category term and adds it to the cached list.
+ *
+ * @return TanStack mutation; call `mutateAsync(name)` to create.
+ */
+export function useCreateCategory() {
+ const queryClient = useQueryClient();
+
+ return useMutation< CategoryTerm, Error, string >( {
+ mutationFn: createCategory,
+ onSuccess: term => {
+ queryClient.setQueryData< CategoryTerm[] >( QUERY_KEY, prev =>
+ prev ? [ ...prev, term ] : [ term ]
+ );
+ },
+ } );
+}
diff --git a/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts
new file mode 100644
index 000000000000..39db8b1e14ea
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts
@@ -0,0 +1,30 @@
+/**
+ * TanStack Query hook for fetching per-episode play stats and durations.
+ *
+ * The wpcom `/wpcom/v2/sites/{id}/podcast-stats/episode-totals` endpoint caches
+ * for 5 minutes server-side; mirror that on the client so we don't refetch on
+ * every tab change.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { fetchEpisodeStats } from '../api';
+import type { EpisodeStats } from '../types';
+
+/**
+ * Read plays + duration for a set of episode post IDs. Sort the IDs first so
+ * the cache key is stable regardless of the order they arrived from the
+ * episodes query.
+ *
+ * @param postIds - Episode post IDs (from the visible page of the table).
+ * @return Query result; `data` is the per-episode stats array.
+ */
+export function useEpisodeStatsQuery( postIds: number[] ) {
+ const sortedIds = [ ...postIds ].sort( ( a, b ) => a - b );
+
+ return useQuery< EpisodeStats[] >( {
+ queryKey: [ 'jetpack-podcast', 'episode-stats', sortedIds ],
+ queryFn: () => fetchEpisodeStats( sortedIds ),
+ enabled: sortedIds.length > 0,
+ staleTime: 5 * 60 * 1000,
+ } );
+}
diff --git a/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts
new file mode 100644
index 000000000000..731f66188d50
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts
@@ -0,0 +1,23 @@
+/**
+ * TanStack Query hook for fetching podcast episodes.
+ */
+
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { fetchEpisodes, type EpisodesQueryArgs, type EpisodesPage } from '../api';
+
+/**
+ * Read a page of podcast episodes. Disabled until a category is configured;
+ * keeps the previous page visible during pagination so the table doesn't flash
+ * empty on each navigation.
+ *
+ * @param args - Pagination, sort, search, and status filter args.
+ * @return Query result with `data.episodes` and pagination metadata.
+ */
+export function useEpisodesQuery( args: EpisodesQueryArgs ) {
+ return useQuery< EpisodesPage >( {
+ queryKey: [ 'jetpack-podcast', 'episodes', args ],
+ queryFn: () => fetchEpisodes( args ),
+ enabled: args.categoryId > 0,
+ placeholderData: keepPreviousData,
+ } );
+}
diff --git a/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts b/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts
new file mode 100644
index 000000000000..a75a96696c96
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts
@@ -0,0 +1,79 @@
+/**
+ * TanStack Query hook for fetching and mutating podcast settings.
+ */
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { dispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { fetchSettings, updateSettings } from '../api';
+import type { PodcastSettings, PodcastSettingsUpdate } from '../types';
+
+const QUERY_KEY = [ 'jetpack-podcast', 'settings' ] as const;
+
+/**
+ * Read the current podcasting_* options as a single TanStack Query object.
+ *
+ * @return Query result; `data` is the resolved settings once loaded.
+ */
+export function usePodcastSettings() {
+ return useQuery< PodcastSettings >( {
+ queryKey: QUERY_KEY,
+ queryFn: fetchSettings,
+ staleTime: 60_000,
+ } );
+}
+
+/**
+ * Mutation for persisting a partial settings update with optimistic UI:
+ * the cache is patched immediately, rolled back on error, and a snackbar
+ * notice (success or failure) is dispatched.
+ *
+ * @return TanStack mutation; call `mutate(partial)` to save.
+ */
+export function useUpdatePodcastSettings() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ PodcastSettings,
+ Error,
+ PodcastSettingsUpdate,
+ { previous?: PodcastSettings }
+ >( {
+ mutationFn: updateSettings,
+ onMutate: async updates => {
+ await queryClient.cancelQueries( { queryKey: QUERY_KEY } );
+ const previous = queryClient.getQueryData< PodcastSettings >( QUERY_KEY );
+ if ( previous ) {
+ // Deep-merge `podcasting_show_urls` so a partial patch (e.g.
+ // { apple: 'url' }) doesn't blow away the other directories'
+ // URLs in the optimistic snapshot. Server merges the same way.
+ const optimistic: PodcastSettings = {
+ ...previous,
+ ...updates,
+ podcasting_show_urls: {
+ ...previous.podcasting_show_urls,
+ ...( updates.podcasting_show_urls ?? {} ),
+ },
+ };
+ queryClient.setQueryData< PodcastSettings >( QUERY_KEY, optimistic );
+ }
+ return { previous };
+ },
+ onError: ( _error, _updates, context ) => {
+ if ( context?.previous ) {
+ queryClient.setQueryData( QUERY_KEY, context.previous );
+ }
+ dispatch( noticesStore ).createErrorNotice(
+ __( 'Could not save your podcast settings. Please try again.', 'jetpack-podcast' ),
+ { type: 'snackbar' }
+ );
+ },
+ onSuccess: data => {
+ queryClient.setQueryData< PodcastSettings >( QUERY_KEY, data );
+ dispatch( noticesStore ).createSuccessNotice( __( 'Settings saved.', 'jetpack-podcast' ), {
+ type: 'snackbar',
+ } );
+ },
+ } );
+}
diff --git a/projects/packages/podcast/src/dashboard/index.tsx b/projects/packages/podcast/src/dashboard/index.tsx
new file mode 100644
index 000000000000..d1004e89e9da
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/index.tsx
@@ -0,0 +1,12 @@
+/**
+ * Podcast SPA bootstrap.
+ */
+
+import { createRoot } from '@wordpress/element';
+import App from './app';
+import './style.scss';
+
+const container = document.getElementById( 'jetpack-podcast-root' );
+if ( container ) {
+ createRoot( container ).render( );
+}
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx
new file mode 100644
index 000000000000..fd5c30c1305a
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx
@@ -0,0 +1,47 @@
+/**
+ * Amazon Music directory entry.
+ */
+
+import type { PodcastApp } from './types';
+
+const AmazonLogo = () => (
+
+
+
+
+
+
+);
+
+export const amazon: PodcastApp = {
+ id: 'amazon',
+ name: 'Amazon Music',
+ Logo: AmazonLogo,
+ submitUrl: 'https://podcasters.amazon.com',
+ showHosts: [
+ 'music.amazon.com',
+ 'music.amazon.co.uk',
+ 'music.amazon.de',
+ 'music.amazon.co.jp',
+ 'music.amazon.com.au',
+ 'music.amazon.fr',
+ 'music.amazon.ca',
+ 'music.amazon.es',
+ ],
+};
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx
new file mode 100644
index 000000000000..e2a89b2f589e
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx
@@ -0,0 +1,63 @@
+/**
+ * Apple Podcasts directory entry.
+ */
+
+import { useId } from '@wordpress/element';
+import type { PodcastApp } from './types';
+
+const AppleLogo = () => {
+ const gradientId = useId();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const apple: PodcastApp = {
+ id: 'apple',
+ name: 'Apple Podcasts',
+ Logo: AppleLogo,
+ submitUrl: 'https://podcastsconnect.apple.com/',
+ learnMoreUrl: 'https://podcasters.apple.com/support/897-submit-a-show',
+ showHosts: [ 'podcasts.apple.com' ],
+};
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/index.ts b/projects/packages/podcast/src/dashboard/podcast-apps/index.ts
new file mode 100644
index 000000000000..213179d3a084
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/index.ts
@@ -0,0 +1,28 @@
+/**
+ * Podcast directory app registry.
+ *
+ * Each entry is a `PodcastApp` config that combines the directory's metadata
+ * with its logo and any per-app UI overrides (extra step content, full modal
+ * replacement, etc.). Entries are ordered as they appear in the Distribution
+ * tab — alphabetical except Pocket Casts surfaces first since it's
+ * historically the smoothest one-click submit on wpcom.
+ */
+
+import { amazon } from './amazon';
+import { apple } from './apple';
+import { pocketcasts } from './pocketcasts';
+import { podcastindex } from './podcastindex';
+import { spotify } from './spotify';
+import { youtube } from './youtube';
+import type { PodcastApp } from './types';
+
+export const PODCAST_APPS: readonly PodcastApp[] = [
+ pocketcasts,
+ apple,
+ spotify,
+ youtube,
+ amazon,
+ podcastindex,
+] as const;
+
+export type { PodcastApp, PodcastAppModalProps } from './types';
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx
new file mode 100644
index 000000000000..b327700b8942
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx
@@ -0,0 +1,48 @@
+/**
+ * Pocket Casts directory entry.
+ *
+ * Carries a `step2Extra` slot reminding listeners to choose the Public option
+ * during submission. When the one-click API submission lands, swap the
+ * `Modal` field in to replace the default 3-step flow entirely.
+ */
+
+// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+import { __experimentalText as Text } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import type { PodcastApp } from './types';
+
+const PocketCastsLogo = () => (
+
+
+
+
+);
+
+export const pocketcasts: PodcastApp = {
+ id: 'pocketcasts',
+ name: 'Pocket Casts',
+ Logo: PocketCastsLogo,
+ submitUrl: 'https://pocketcasts.com/submit',
+ learnMoreUrl: 'https://support.pocketcasts.com/knowledge-base/submitting-podcasts/',
+ showHosts: [ 'pca.st', 'pocketcasts.com' ],
+ step2Extra: (
+
+ { __(
+ 'Choose the Public option, since this feed is for your listeners.',
+ 'jetpack-podcast'
+ ) }
+
+ ),
+};
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx
new file mode 100644
index 000000000000..84694de99478
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx
@@ -0,0 +1,31 @@
+/**
+ * Podcast Index directory entry.
+ */
+
+import type { PodcastApp } from './types';
+
+const PodcastIndexLogo = () => (
+
+
+
+
+
+
+
+
+);
+
+export const podcastindex: PodcastApp = {
+ id: 'podcastindex',
+ name: 'Podcast Index',
+ Logo: PodcastIndexLogo,
+ submitUrl: 'https://podcastindex.org/add',
+ showHosts: [ 'podcastindex.org' ],
+};
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx
new file mode 100644
index 000000000000..2a6c2e9dfb02
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx
@@ -0,0 +1,35 @@
+/**
+ * Spotify directory entry.
+ */
+
+import type { PodcastApp } from './types';
+
+const SpotifyLogo = () => (
+
+
+
+
+
+
+
+);
+
+export const spotify: PodcastApp = {
+ id: 'spotify',
+ name: 'Spotify',
+ Logo: SpotifyLogo,
+ submitUrl: 'https://creators.spotify.com/',
+ learnMoreUrl:
+ 'https://support.spotify.com/creators/article/claiming-your-podcast-on-spotify-for-creators/',
+ showHosts: [ 'open.spotify.com' ],
+};
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/types.ts b/projects/packages/podcast/src/dashboard/podcast-apps/types.ts
new file mode 100644
index 000000000000..8290e8ee2106
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/types.ts
@@ -0,0 +1,44 @@
+/**
+ * Shared types for the podcast directory app registry.
+ *
+ * Each podcast app (Apple, Spotify, etc.) lives in its own file under
+ * `podcast-apps/` and exports a `PodcastApp` config. The data-only fields
+ * cover what every app needs (id, name, logo, submit URL); the optional
+ * fields let an app contribute UI without forking the whole flow.
+ *
+ * `step2Extra` is extra JSX rendered inside Step 2 of the default modal
+ * (e.g. "Choose the Public option" for Pocket Casts). `Modal` is a full
+ * replacement for the default 3-step submit modal, used when an app's
+ * flow doesn't fit "copy feed URL, visit page, paste URL back" (e.g.
+ * one-click API submission).
+ */
+
+import type { PodcatcherId } from '../types';
+import type { ComponentType, ReactNode } from 'react';
+
+export interface PodcastApp {
+ id: PodcatcherId;
+ name: string;
+ Logo: ComponentType;
+ /** External submission page; ignored when `Modal` is set. */
+ submitUrl: string;
+ /** Optional "learn more" link shown inside the default modal. */
+ learnMoreUrl?: string;
+ /**
+ * Hostnames a saved show URL is allowed to live on (lowercase, no `www.`).
+ * Mirrors `SHOW_URL_HOSTS` in src/rest/class-settings-rest.php so the
+ * client can reject obviously-wrong URLs before round-tripping; the
+ * server enforces the same allowlist authoritatively.
+ */
+ showHosts: readonly string[];
+ /** Optional content rendered below the standard Step 2 copy. */
+ step2Extra?: ReactNode;
+ /** Override the default 3-step submit modal entirely. */
+ Modal?: ComponentType< PodcastAppModalProps >;
+}
+
+export interface PodcastAppModalProps {
+ app: PodcastApp;
+ feedUrl: string;
+ onClose: () => void;
+}
diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx
new file mode 100644
index 000000000000..a41e45c68d5b
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx
@@ -0,0 +1,31 @@
+/**
+ * YouTube directory entry.
+ */
+
+import type { PodcastApp } from './types';
+
+const YouTubeLogo = () => (
+
+
+
+
+);
+
+export const youtube: PodcastApp = {
+ id: 'youtube',
+ name: 'YouTube',
+ Logo: YouTubeLogo,
+ submitUrl: 'https://studio.youtube.com',
+ learnMoreUrl: 'https://support.google.com/youtube/answer/13973017',
+ showHosts: [ 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ],
+};
diff --git a/projects/packages/podcast/src/dashboard/script-data.ts b/projects/packages/podcast/src/dashboard/script-data.ts
new file mode 100644
index 000000000000..f1c62965b82d
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/script-data.ts
@@ -0,0 +1,38 @@
+/**
+ * Reads the `podcast` slice injected into `JetpackScriptData` by class-settings.php.
+ *
+ * The script data is static for the lifetime of the page, so we resolve it once
+ * on first read and hand back the same frozen object on every subsequent call.
+ */
+
+import { getScriptData } from '@automattic/jetpack-script-data';
+import type { PodcastScriptData } from './types';
+
+const DEFAULTS: PodcastScriptData = {
+ categoryId: 0,
+ feedUrl: '',
+ siteUrl: '',
+ adminUrl: '',
+ editPostUrlBase: '',
+ newPostUrl: '',
+ mediaLibraryUrl: '',
+ userEmail: '',
+ dateFormat: 'F j, Y',
+};
+
+let cached: PodcastScriptData | null = null;
+
+/**
+ * Resolve the podcast script-data slice on first call and return the same
+ * frozen object on every subsequent call.
+ *
+ * @return The page's podcast script data, with defaults filled in for missing keys.
+ */
+export function getPodcastScriptData(): PodcastScriptData {
+ if ( cached ) {
+ return cached;
+ }
+ const data = getScriptData() as { podcast?: Partial< PodcastScriptData > };
+ cached = Object.freeze( { ...DEFAULTS, ...data.podcast } ) as PodcastScriptData;
+ return cached;
+}
diff --git a/projects/packages/podcast/src/dashboard/style.scss b/projects/packages/podcast/src/dashboard/style.scss
new file mode 100644
index 000000000000..5f67bbf19676
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/style.scss
@@ -0,0 +1,298 @@
+/**
+ * Jetpack Podcast SPA styles.
+ *
+ * Adapted from `client/my-sites/podcast/style.scss` in Calypso. Calypso-only
+ * design tokens (`--wpds-*` / `--studio-*`) have been replaced with literal
+ * fallbacks so the styles work in plain wp-admin without any token plumbing.
+ * Class names are preserved (`podcast__*`) so the hand-off back to Calypso (if
+ * any) remains a near-no-op.
+ */
+
+@use "@wordpress/dataviews/build-style/style.css";
+
+.podcast__loading {
+ display: flex;
+ justify-content: center;
+ padding: 48px 0;
+}
+
+.podcast__tabs-bar {
+ border-block-end: 1px solid #dcdcde;
+ margin-block-end: 24px;
+}
+
+.podcast__tabs [role="tab"] {
+ padding-inline: 16px;
+}
+
+.podcast__tab-content {
+ padding-block: 8px;
+}
+
+.podcast__section-header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-block-end: 24px;
+}
+
+.podcast__section-heading {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 400;
+ line-height: 1.2;
+ color: #1e1e1e;
+}
+
+.podcast__section-description {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.4;
+ color: #757575;
+}
+
+.podcast__card-title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: #1e1e1e;
+}
+
+.podcast__settings {
+ max-inline-size: 800px;
+}
+
+.podcast__settings-issues {
+ margin: 4px 0 0;
+ padding-inline-start: 18px;
+ list-style: disc;
+}
+
+.podcast__cover-control {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ column-gap: 16px;
+ row-gap: 8px;
+ align-items: start;
+}
+
+.podcast__cover-preview {
+ grid-column: 1;
+ grid-row: 1 / span 2;
+ width: 220px;
+ height: 220px;
+ overflow: hidden;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+ background: #f6f7f7;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.podcast__cover-placeholder {
+ font-size: 12px;
+ color: #757575;
+}
+
+.podcast__cover-actions {
+ grid-column: 2;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.podcast__cover-warning {
+ grid-column: 2;
+ margin: 0;
+ font-size: 12px;
+ color: #b26200;
+}
+
+.podcast__directory-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.podcast__directory-row {
+ padding-block: 12px;
+ border-block-end: 1px solid #dcdcde;
+
+ &:last-child {
+ border-block-end: none;
+ }
+}
+
+.podcast__feed-copy {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.podcast__feed-copy-input {
+ flex: 1;
+ min-width: 0;
+ padding: 6px 10px;
+ font-family: ui-monospace, SFMono-Regular, monospace;
+ font-size: 13px;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+ background: #f6f7f7;
+ color: #1e1e1e;
+}
+
+.podcast__welcome-hero {
+ padding: 40px;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #f0f6fc 0%, #fdf3fc 100%);
+}
+
+.podcast__welcome-title {
+ margin: 0;
+ font-size: 32px;
+ font-weight: 400;
+ line-height: 1.15;
+ letter-spacing: -0.01em;
+}
+
+.podcast__welcome-hero-copy {
+ max-width: 640px;
+}
+
+.podcast__welcome-benefit-icon {
+ display: inline-flex;
+ align-items: center;
+ color: #1e1e1e;
+
+ svg {
+ inline-size: 24px;
+ block-size: 24px;
+ fill: currentColor;
+ }
+}
+
+.podcast__welcome-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
+ gap: 24px;
+}
+
+.podcast__welcome-step {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.podcast__welcome-step-circle {
+ display: grid;
+ place-items: center;
+ inline-size: 32px;
+ block-size: 32px;
+ border-radius: 50%;
+ background: #2c3338;
+ color: #fff;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.podcast__episode-thumb {
+ width: 56px;
+ height: 56px;
+ object-fit: cover;
+ border-radius: 4px;
+}
+
+.podcast__episode-thumb--placeholder {
+ background: #f0f0f1;
+ border: 1px dashed #dcdcde;
+}
+
+.podcast__empty-state {
+ padding: 48px 16px;
+ text-align: center;
+ color: #757575;
+}
+
+.podcast__submit-modal .components-modal__content {
+ max-width: 520px;
+}
+
+.podcast__submit-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.podcast__submit-step {
+ padding: 16px;
+ background: #f6f7f7;
+ border-radius: 8px;
+}
+
+.podcast__submit-step-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e1e1e;
+}
+
+.podcast__submit-step-row {
+
+ @media ( max-width: 600px ) {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+.podcast__submit-step-field {
+ flex: 1;
+ min-width: 0;
+}
+
+.podcast__submit-copy-button {
+ min-inline-size: 8rem;
+ align-self: flex-start;
+ justify-content: flex-start;
+}
+
+.podcast__submit-step-notice {
+ margin-block: 8px 0;
+ margin-inline: 0;
+}
+
+.podcast__submit-step-saved {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 12px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+}
+
+.podcast__submit-step-saved-icon {
+ flex-shrink: 0;
+ color: #007a3d;
+
+ // Gutenberg's Icon paths use fill="currentColor"; force any stray fill="none"
+ // or hard-coded colors to inherit so the success token actually wins.
+ svg {
+ fill: currentColor;
+ }
+}
+
+.podcast__submit-step-saved-url {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
diff --git a/projects/packages/podcast/src/dashboard/tabs/distribution.tsx b/projects/packages/podcast/src/dashboard/tabs/distribution.tsx
new file mode 100644
index 000000000000..f02a1d15101f
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/tabs/distribution.tsx
@@ -0,0 +1,177 @@
+/**
+ * Distribution tab — copy-the-feed CTA + per-directory submit buttons.
+ *
+ * Each podcast directory ("podcast app") lives in its own self-contained
+ * file under `../podcast-apps/`. This tab just renders the registry and
+ * delegates the submission flow to either the default 3-step `SubmitModal`
+ * or, when an app sets `Modal`, that app's custom modal.
+ */
+
+import {
+ Button,
+ Card,
+ CardBody,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalText as Text,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useCopyToClipboard } from '@wordpress/compose';
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { check, copy } from '@wordpress/icons';
+import SubmitModal from '../components/submit-modal';
+import { usePodcastSettings } from '../hooks/use-podcast-settings';
+import { PODCAST_APPS } from '../podcast-apps';
+import { getPodcastScriptData } from '../script-data';
+import type { PodcatcherId } from '../types';
+import type { FocusEvent } from 'react';
+
+const selectOnFocus = ( event: FocusEvent< HTMLInputElement > ) => {
+ event.currentTarget.select();
+};
+
+// Pre-resolved so the i18n-check-webpack-plugin validator sees two distinct
+// __() calls in the bundled output instead of __(cond?'a':'b'). Hoisted out of
+// the component since these strings don't depend on props or state.
+const COPIED_LABEL = __( 'Copied!', 'jetpack-podcast' );
+const COPY_LINK_LABEL = __( 'Copy link', 'jetpack-podcast' );
+
+const FeedCopyField = ( { value }: { value: string } ) => {
+ const [ copied, setCopied ] = useState( false );
+
+ const copyRef = useCopyToClipboard< HTMLButtonElement >( value, () => {
+ setCopied( true );
+ setTimeout( () => setCopied( false ), 2000 );
+ } );
+
+ return (
+
+
+
+ { copied ? COPIED_LABEL : COPY_LINK_LABEL }
+
+
+ );
+};
+
+const DistributionTab = () => {
+ const { data: settings } = usePodcastSettings();
+ const scriptData = getPodcastScriptData();
+ const feedUrl = scriptData.feedUrl;
+ const isEnabled = !! settings?.podcasting_category_id;
+
+ const [ activeId, setActiveId ] = useState< PodcatcherId | null >( null );
+ const activeApp = PODCAST_APPS.find( a => a.id === activeId ) ?? null;
+
+ const handleClose = useCallback( () => {
+ setActiveId( null );
+ }, [] );
+
+ const ActiveModal = activeApp?.Modal ?? SubmitModal;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ { __( 'RSS feed', 'jetpack-podcast' ) }
+
+ { __(
+ 'Copy this URL, then submit it to each directory below to publish your podcast.',
+ 'jetpack-podcast'
+ ) }
+
+
+ { isEnabled && feedUrl ? (
+
+ ) : (
+
+ { __(
+ 'Set your podcast category to generate the feed URL you can submit to directories.',
+ 'jetpack-podcast'
+ ) }
+
+ ) }
+
+
+
+
+
+ { __( 'Podcast directories', 'jetpack-podcast' ) }
+
+
+ { __(
+ 'Submit your podcast to the directories below where you want it to appear. Most take a few days to go live.',
+ 'jetpack-podcast'
+ ) }
+
+
+
+ { PODCAST_APPS.map( app => {
+ const { Logo } = app;
+ return (
+
+
+
+
+
+ { app.name }
+
+ setActiveId( app.id ) }
+ disabled={ ! isEnabled }
+ >
+ { __( 'Submit', 'jetpack-podcast' ) }
+
+
+ );
+ } ) }
+
+
+
+
+
+
+ { activeApp && }
+ >
+ );
+};
+
+export default DistributionTab;
diff --git a/projects/packages/podcast/src/dashboard/tabs/episodes.tsx b/projects/packages/podcast/src/dashboard/tabs/episodes.tsx
new file mode 100644
index 000000000000..bf5c938b3794
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/tabs/episodes.tsx
@@ -0,0 +1,275 @@
+/**
+ * Episodes tab — DataViews list of posts in the configured podcast category.
+ *
+ * Server-side ordering covers `date` and `title`; duration and plays come from
+ * the wpcom `podcast-stats/episode-totals` endpoint and are merged client-side,
+ * so those columns are display-only (not sortable).
+ */
+
+import { DataViews, type Action, type View, type ViewTable } from '@wordpress/dataviews';
+import { useMemo, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __ } from '@wordpress/i18n';
+import { useEpisodeStatsQuery } from '../hooks/use-episode-stats-query';
+import { useEpisodesQuery } from '../hooks/use-episodes-query';
+import { usePodcastSettings } from '../hooks/use-podcast-settings';
+import { getPodcastScriptData } from '../script-data';
+import type { EpisodeStats } from '../types';
+
+interface EpisodeRow {
+ id: number;
+ title: string;
+ date: string;
+ status: string;
+ link: string;
+ featuredMediaUrl: string;
+ playsAll: number;
+ durationSeconds: number | null;
+}
+
+const formatDuration = ( seconds: number | null ): string => {
+ if ( seconds == null || seconds <= 0 ) {
+ return '—';
+ }
+ const h = Math.floor( seconds / 3600 );
+ const m = Math.floor( ( seconds % 3600 ) / 60 );
+ const s = seconds % 60;
+ const pad = ( n: number ) => String( n ).padStart( 2, '0' );
+ return h > 0 ? `${ h }:${ pad( m ) }:${ pad( s ) }` : `${ m }:${ pad( s ) }`;
+};
+
+const defaultView: ViewTable = {
+ type: 'table',
+ titleField: 'title',
+ mediaField: 'media',
+ showTitle: true,
+ showMedia: true,
+ fields: [ 'duration', 'plays', 'date', 'status' ],
+ page: 1,
+ perPage: 20,
+ sort: { field: 'date', direction: 'desc' },
+ layout: {
+ styles: {
+ media: { width: '72px' },
+ title: { width: 'auto', minWidth: '260px' },
+ duration: { width: '110px' },
+ plays: { width: '120px' },
+ date: { width: '150px' },
+ status: { width: '140px' },
+ },
+ },
+};
+
+const getEpisodeRowId = ( item: EpisodeRow ) => String( item.id );
+
+const STATUS_LABELS: Record< string, string > = {
+ publish: __( 'Published', 'jetpack-podcast' ),
+ future: __( 'Scheduled', 'jetpack-podcast' ),
+ draft: __( 'Draft', 'jetpack-podcast' ),
+ pending: __( 'Pending review', 'jetpack-podcast' ),
+ private: __( 'Private', 'jetpack-podcast' ),
+};
+
+const EpisodesTab = () => {
+ const { data: settings } = usePodcastSettings();
+ const categoryId = settings?.podcasting_category_id ?? 0;
+ const scriptData = getPodcastScriptData();
+
+ const [ view, setView ] = useState< View >( defaultView );
+
+ const queryArgs = useMemo( () => {
+ const sortField = view.sort?.field;
+ const orderBy = sortField === 'title' || sortField === 'date' ? sortField : 'date';
+ const order = view.sort?.direction === 'asc' ? 'asc' : 'desc';
+ const statusFilter = view.filters?.find( filter => filter.field === 'status' );
+ const status =
+ typeof statusFilter?.value === 'string' && statusFilter.value ? statusFilter.value : 'any';
+
+ return {
+ categoryId,
+ page: view.page ?? 1,
+ perPage: view.perPage ?? 20,
+ orderBy: orderBy as 'date' | 'title',
+ order: order as 'asc' | 'desc',
+ search: view.search ?? '',
+ status,
+ };
+ }, [ categoryId, view ] );
+
+ const { data: episodesPage, isLoading } = useEpisodesQuery( queryArgs );
+ // Wrap in useMemo so the array identity is stable when episodesPage hasn't
+ // changed — otherwise the `?? []` fallback creates a new array on every
+ // render and invalidates dependent useMemos below.
+ const posts = useMemo( () => episodesPage?.episodes ?? [], [ episodesPage ] );
+
+ const postIds = useMemo( () => posts.map( p => p.id ), [ posts ] );
+ const { data: stats = [] } = useEpisodeStatsQuery( postIds );
+
+ const statsByPostId = useMemo( () => {
+ const m = new Map< number, EpisodeStats >();
+ for ( const s of stats ) {
+ m.set( s.post_id, s );
+ }
+ return m;
+ }, [ stats ] );
+
+ const rows = useMemo< EpisodeRow[] >( () => {
+ return posts.map( post => {
+ const media = post._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ];
+ const sizes = media?.media_details?.sizes;
+ const thumbnail =
+ sizes?.thumbnail?.source_url ?? sizes?.medium?.source_url ?? media?.source_url ?? '';
+ const stat = statsByPostId.get( post.id );
+ return {
+ id: post.id,
+ title: decodeEntities( post.title?.rendered ?? '' ),
+ date: post.date,
+ status: post.status,
+ link: post.link,
+ featuredMediaUrl: thumbnail,
+ playsAll: stat?.plays_all_time ?? 0,
+ durationSeconds: stat?.duration_seconds ?? null,
+ };
+ } );
+ }, [ posts, statsByPostId ] );
+
+ const fields = useMemo(
+ () => [
+ {
+ id: 'media',
+ label: __( 'Featured image', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.featuredMediaUrl,
+ render: ( { item }: { item: EpisodeRow } ) =>
+ item.featuredMediaUrl ? (
+
+ ) : (
+
+ ),
+ enableHiding: false,
+ enableSorting: false,
+ },
+ {
+ id: 'title',
+ label: __( 'Title', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.title,
+ render: ( { item }: { item: EpisodeRow } ) => (
+
+ { item.title || __( '(Untitled)', 'jetpack-podcast' ) }
+
+ ),
+ enableHiding: false,
+ enableSorting: true,
+ enableGlobalSearch: true,
+ },
+ {
+ id: 'duration',
+ type: 'integer' as const,
+ label: __( 'Duration', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.durationSeconds ?? 0,
+ render: ( { item }: { item: EpisodeRow } ) => formatDuration( item.durationSeconds ),
+ enableSorting: false,
+ },
+ {
+ id: 'plays',
+ type: 'integer' as const,
+ label: __( 'Plays', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.playsAll,
+ enableSorting: false,
+ },
+ {
+ id: 'date',
+ type: 'datetime' as const,
+ label: __( 'Date', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.date,
+ format: { datetime: 'M j, Y' },
+ enableSorting: true,
+ },
+ {
+ id: 'status',
+ label: __( 'Status', 'jetpack-podcast' ),
+ getValue: ( { item }: { item: EpisodeRow } ) => item.status,
+ render: ( { item }: { item: EpisodeRow } ) => STATUS_LABELS[ item.status ] ?? item.status,
+ elements: Object.entries( STATUS_LABELS ).map( ( [ value, label ] ) => ( {
+ value,
+ label,
+ } ) ),
+ filterBy: { operators: [ 'is' as const ] },
+ enableSorting: true,
+ },
+ ],
+ [ scriptData.editPostUrlBase ]
+ );
+
+ const actions = useMemo< Action< EpisodeRow >[] >(
+ () => [
+ {
+ id: 'edit',
+ label: __( 'Edit', 'jetpack-podcast' ),
+ callback: ( items: EpisodeRow[] ) => {
+ const item = items[ 0 ];
+ if ( item ) {
+ window.location.href = `${ scriptData.editPostUrlBase }${ item.id }`;
+ }
+ },
+ },
+ {
+ id: 'view',
+ label: __( 'View', 'jetpack-podcast' ),
+ callback: ( items: EpisodeRow[] ) => {
+ const item = items[ 0 ];
+ if ( item?.link ) {
+ window.open( item.link, '_blank', 'noopener,noreferrer' );
+ }
+ },
+ },
+ ],
+ [ scriptData.editPostUrlBase ]
+ );
+
+ if ( ! categoryId ) {
+ return (
+
+
+ { __( 'No podcast episodes yet.', 'jetpack-podcast' ) }
+
+
+ { __(
+ 'Set a podcast category in your podcasting settings to start showing episodes here.',
+ 'jetpack-podcast'
+ ) }
+
+
+ );
+ }
+
+ return (
+
+
+
+ data={ rows }
+ fields={ fields }
+ view={ view }
+ onChangeView={ setView }
+ actions={ actions }
+ paginationInfo={ {
+ totalItems: episodesPage?.total ?? 0,
+ totalPages: episodesPage?.totalPages ?? 0,
+ } }
+ getItemId={ getEpisodeRowId }
+ isLoading={ isLoading }
+ defaultLayouts={ { table: {} } }
+ search
+ />
+
+ );
+};
+
+export default EpisodesTab;
diff --git a/projects/packages/podcast/src/dashboard/tabs/settings.tsx b/projects/packages/podcast/src/dashboard/tabs/settings.tsx
new file mode 100644
index 000000000000..538e0e9a0880
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/tabs/settings.tsx
@@ -0,0 +1,509 @@
+/**
+ * Settings tab — podcast metadata form.
+ *
+ * Mirrors `client/my-sites/podcast/components/settings.tsx` from Calypso, but
+ * persists through `usePodcastSettings`/`useUpdatePodcastSettings` (TanStack
+ * Query) instead of the Calypso `wrapSettingsForm` HOC. Validation matches
+ * the Apple Podcasts requirements: title, summary, talent name, owner email,
+ * primary topic, and a 1400–3000px square cover image.
+ */
+
+import {
+ BaseControl,
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ Modal,
+ Notice,
+ SelectControl,
+ TextControl,
+ TextareaControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import CoverImageControl from '../components/cover-image-control';
+import { useCategoriesQuery, useCreateCategory } from '../hooks/use-categories-query';
+import { usePodcastSettings, useUpdatePodcastSettings } from '../hooks/use-podcast-settings';
+import { TOPICS, type Topic } from '../topics';
+import type { PodcastSettings, ExplicitValue } from '../types';
+
+const EXPLICIT_OPTIONS: Array< { label: string; value: ExplicitValue } > = [
+ { label: __( 'No', 'jetpack-podcast' ), value: 'no' },
+ { label: __( 'Yes', 'jetpack-podcast' ), value: 'yes' },
+ { label: __( 'Clean', 'jetpack-podcast' ), value: 'clean' },
+];
+
+const TOPICS_FIELD_ID = 'jetpack-podcast-topics';
+
+const splitStored = ( stored: string ): { primary: string; sub: string } => {
+ const [ primary = '', sub = '' ] = stored.split( ',' ).map( s => s.trim() );
+ return { primary, sub };
+};
+
+const joinStored = ( primary: string, sub: string ): string => {
+ if ( ! primary ) {
+ return '';
+ }
+ return sub ? `${ primary },${ sub }` : primary;
+};
+
+const getValidationIssues = ( settings: PodcastSettings | undefined ): string[] => {
+ if ( ! settings ) {
+ return [];
+ }
+ const issues: string[] = [];
+ if ( ! settings.podcasting_category_id ) {
+ issues.push( __( 'Choose a category to use as your podcast feed.', 'jetpack-podcast' ) );
+ }
+ if ( ! settings.podcasting_title ) {
+ issues.push( __( 'Add a podcast title.', 'jetpack-podcast' ) );
+ }
+ if ( ! settings.podcasting_summary ) {
+ issues.push(
+ __( 'Write a short summary so listeners know what your show is about.', 'jetpack-podcast' )
+ );
+ }
+ if ( ! settings.podcasting_talent_name ) {
+ issues.push( __( 'Set the host or talent name.', 'jetpack-podcast' ) );
+ }
+ if ( ! settings.podcasting_email ) {
+ issues.push(
+ __( 'Add an owner email so podcast directories can reach you.', 'jetpack-podcast' )
+ );
+ }
+ if ( ! settings.podcasting_category_1 ) {
+ issues.push( __( 'Pick at least one Apple Podcasts category.', 'jetpack-podcast' ) );
+ }
+ if ( ! settings.podcasting_image ) {
+ issues.push( __( 'Upload a cover image (1400×1400 to 3000×3000 pixels).', 'jetpack-podcast' ) );
+ }
+ return issues;
+};
+
+interface TopicPickerProps {
+ value: string;
+ onChange: ( next: string ) => void;
+ label: string;
+ disabled?: boolean;
+}
+
+const TopicPicker = ( { value, onChange, label, disabled }: TopicPickerProps ) => {
+ const { primary, sub } = splitStored( value );
+ const selectedTopic = TOPICS.find( ( t: Topic ) => t.key === primary );
+ const subOptions = selectedTopic?.subtopics ?? [];
+
+ const onPrimaryChange = useCallback(
+ ( next: string ) => {
+ onChange( joinStored( next, '' ) );
+ },
+ [ onChange ]
+ );
+
+ const onSubChange = useCallback(
+ ( next: string ) => {
+ onChange( joinStored( primary, next ) );
+ },
+ [ onChange, primary ]
+ );
+
+ return (
+
+ ( { label: topic.label, value: topic.key } ) ),
+ ] }
+ />
+ { subOptions.length > 0 && (
+ ( { label: s.label, value: s.key } ) ),
+ ] }
+ />
+ ) }
+
+ );
+};
+
+const SettingsTab = () => {
+ const { data: settings, isLoading } = usePodcastSettings();
+ const { mutate: saveSettings, isPending: isSaving } = useUpdatePodcastSettings();
+
+ const [ draft, setDraft ] = useState< PodcastSettings | null >( null );
+ const [ isCreatingCategory, setIsCreatingCategory ] = useState( false );
+ const [ newCategoryName, setNewCategoryName ] = useState( '' );
+ const [ confirmDisable, setConfirmDisable ] = useState( false );
+
+ useEffect( () => {
+ if ( settings && ! draft ) {
+ setDraft( settings );
+ }
+ }, [ settings, draft ] );
+
+ const { data: categories = [] } = useCategoriesQuery();
+ const { mutateAsync: createCategoryAsync, isPending: isCreatingCategoryPending } =
+ useCreateCategory();
+
+ const setField = useCallback(
+ < K extends keyof PodcastSettings >( key: K, value: PodcastSettings[ K ] ) => {
+ setDraft( prev => ( prev ? { ...prev, [ key ]: value } : prev ) );
+ },
+ []
+ );
+
+ // Per-field stable handlers — `useCallback`'d once with `setField` as the
+ // only dep so they can be passed straight to JSX without re-allocating
+ // every render.
+ const onCategoryIdChange = useCallback(
+ ( value: string ) => setField( 'podcasting_category_id', Number( value ) || 0 ),
+ [ setField ]
+ );
+ const onTitleChange = useCallback(
+ ( value: string ) => setField( 'podcasting_title', value ),
+ [ setField ]
+ );
+ const onSummaryChange = useCallback(
+ ( value: string ) => setField( 'podcasting_summary', value ),
+ [ setField ]
+ );
+ const onTalentNameChange = useCallback(
+ ( value: string ) => setField( 'podcasting_talent_name', value ),
+ [ setField ]
+ );
+ const onCopyrightChange = useCallback(
+ ( value: string ) => setField( 'podcasting_copyright', value ),
+ [ setField ]
+ );
+ const onExplicitChange = useCallback(
+ ( value: string ) => setField( 'podcasting_explicit', value as ExplicitValue ),
+ [ setField ]
+ );
+ const onEmailChange = useCallback(
+ ( value: string ) => setField( 'podcasting_email', value ),
+ [ setField ]
+ );
+ const onTopic1Change = useCallback(
+ ( value: string ) => setField( 'podcasting_category_1', value ),
+ [ setField ]
+ );
+ const onTopic2Change = useCallback(
+ ( value: string ) => setField( 'podcasting_category_2', value ),
+ [ setField ]
+ );
+ const onTopic3Change = useCallback(
+ ( value: string ) => setField( 'podcasting_category_3', value ),
+ [ setField ]
+ );
+
+ const onCoverImageSelect = useCallback( ( id: number, url: string ) => {
+ setDraft( prev =>
+ prev ? { ...prev, podcasting_image: url, podcasting_image_id: id } : prev
+ );
+ }, [] );
+
+ const onCoverImageRemove = useCallback( () => {
+ setDraft( prev => ( prev ? { ...prev, podcasting_image: '', podcasting_image_id: 0 } : prev ) );
+ }, [] );
+
+ const openCreateCategory = useCallback( () => setIsCreatingCategory( true ), [] );
+ const closeCreateCategory = useCallback( () => setIsCreatingCategory( false ), [] );
+ const openConfirmDisable = useCallback( () => setConfirmDisable( true ), [] );
+ const closeConfirmDisable = useCallback( () => setConfirmDisable( false ), [] );
+
+ const isDirty = useMemo( () => {
+ if ( ! settings || ! draft ) {
+ return false;
+ }
+ return ( Object.keys( draft ) as Array< keyof PodcastSettings > ).some(
+ key => draft[ key ] !== settings[ key ]
+ );
+ }, [ settings, draft ] );
+
+ const issues = useMemo( () => getValidationIssues( draft ?? settings ), [ draft, settings ] );
+
+ const onSave = useCallback( () => {
+ if ( ! draft ) {
+ return;
+ }
+ saveSettings( draft );
+ }, [ draft, saveSettings ] );
+
+ const onCreateCategory = useCallback( async () => {
+ const name = newCategoryName.trim();
+ if ( ! name ) {
+ return;
+ }
+ const term = await createCategoryAsync( name );
+ setField( 'podcasting_category_id', term.id );
+ setNewCategoryName( '' );
+ setIsCreatingCategory( false );
+ }, [ newCategoryName, createCategoryAsync, setField ] );
+
+ const onDisablePodcasting = useCallback( () => {
+ setField( 'podcasting_category_id', 0 );
+ setConfirmDisable( false );
+ // Push the disable through immediately rather than waiting for a manual save click —
+ // the user explicitly confirmed.
+ saveSettings( { podcasting_category_id: 0 } );
+ }, [ setField, saveSettings ] );
+
+ if ( isLoading || ! draft ) {
+ return null;
+ }
+
+ return (
+
+ { issues.length > 0 && (
+
+ { __( 'Finish setting up your podcast', 'jetpack-podcast' ) }
+
+ { issues.map( issue => (
+ { issue }
+ ) ) }
+
+
+ ) }
+
+
+
+
+ { __( 'Podcast category', 'jetpack-podcast' ) }
+
+
+
+
+ ( { label: cat.name, value: String( cat.id ) } ) ),
+ ] }
+ />
+
+ { __( 'New category', 'jetpack-podcast' ) }
+
+
+
+
+
+
+
+ { __( 'Show details', 'jetpack-podcast' ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { __( 'Feed settings', 'jetpack-podcast' ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { draft.podcasting_category_id > 0 && (
+
+ { __( 'Disable podcasting', 'jetpack-podcast' ) }
+
+ ) }
+
+ { __( 'Save changes', 'jetpack-podcast' ) }
+
+
+
+ { isCreatingCategory && (
+
+
+
+
+
+ { __( 'Cancel', 'jetpack-podcast' ) }
+
+
+ { __( 'Create category', 'jetpack-podcast' ) }
+
+
+
+
+ ) }
+
+ { confirmDisable && (
+
+
+
+ { __(
+ 'Your podcast feed will stop being generated. Existing episodes stay in the assigned category and you can turn podcasting back on at any time.',
+ 'jetpack-podcast'
+ ) }
+
+
+
+ { __( 'Cancel', 'jetpack-podcast' ) }
+
+
+ { __( 'Disable podcasting', 'jetpack-podcast' ) }
+
+
+
+
+ ) }
+
+ );
+};
+
+export default SettingsTab;
diff --git a/projects/packages/podcast/src/dashboard/tabs/welcome.tsx b/projects/packages/podcast/src/dashboard/tabs/welcome.tsx
new file mode 100644
index 000000000000..4cdffc68aaff
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/tabs/welcome.tsx
@@ -0,0 +1,143 @@
+/**
+ * Welcome tab — onboarding hero, benefits, and "how it works" steps.
+ *
+ * Ported from `client/my-sites/podcast/components/welcome.tsx` in Calypso. The
+ * WordPress.com pricing cards have been dropped: this page is gated to Simple
+ * and Atomic, so the user already has access. If a plan-tier upsell becomes
+ * necessary later, drop a `ContextualUpgradeTrigger` in below the hero rather
+ * than restoring the pricing cards.
+ */
+
+import {
+ Button,
+ Card,
+ CardBody,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalText as Text,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { Icon, audio, layout, megaphone } from '@wordpress/icons';
+
+interface WelcomeTabProps {
+ onGetStarted: () => void;
+}
+
+// Resolved once at module load (this whole module is itself lazy-loaded, so
+// the `__()` calls run after wp-i18n is initialised). Hoisting avoids
+// re-allocating the array + re-running translations on every render.
+const BENEFITS: ReadonlyArray< { icon: JSX.Element; title: string; body: string } > = [
+ {
+ icon: ,
+ title: __( 'Reach listeners in every app', 'jetpack-podcast' ),
+ body: __(
+ 'One feed distributes to Apple Podcasts, Spotify, Overcast, Pocket Casts, and every directory that accepts RSS.',
+ 'jetpack-podcast'
+ ),
+ },
+ {
+ icon: ,
+ title: __( 'Works with the editor you already use', 'jetpack-podcast' ),
+ body: __(
+ 'Drop an audio block into a post, assign the podcast category, hit publish. That is the whole workflow.',
+ 'jetpack-podcast'
+ ),
+ },
+ {
+ icon: ,
+ title: __( 'One home for writing, email, and audio', 'jetpack-podcast' ),
+ body: __(
+ 'One site, one audience, one subscriber list. Your posts, newsletters, and episodes all live in the same place.',
+ 'jetpack-podcast'
+ ),
+ },
+];
+
+const STEPS: ReadonlyArray< { number: string; title: string; body: string } > = [
+ {
+ number: '1',
+ title: __( 'Pick a category', 'jetpack-podcast' ),
+ body: __( 'Choose or create the category that holds your episodes.', 'jetpack-podcast' ),
+ },
+ {
+ number: '2',
+ title: __( 'Publish a post with audio', 'jetpack-podcast' ),
+ body: __(
+ 'Add an audio block to any post and assign it to your podcast category.',
+ 'jetpack-podcast'
+ ),
+ },
+ {
+ number: '3',
+ title: __( 'Submit your feed once', 'jetpack-podcast' ),
+ body: __(
+ 'Copy the feed URL, submit it to Apple Podcasts and Spotify, and you are live.',
+ 'jetpack-podcast'
+ ),
+ },
+];
+
+const WelcomeTab = ( { onGetStarted }: WelcomeTabProps ) => (
+
+
+
+
+ { __( 'Turn your posts into a podcast', 'jetpack-podcast' ) }
+
+
+ { __(
+ 'Publish audio alongside your writing and get distributed to Apple Podcasts, Spotify, and every major app, without leaving your site.',
+ 'jetpack-podcast'
+ ) }
+
+
+
+ { __( 'Enable podcasting', 'jetpack-podcast' ) }
+
+
+
+
+
+
+ { BENEFITS.map( b => (
+
+
+
+
+ { b.icon }
+
+
+ { b.title }
+
+ { b.body }
+
+
+
+ ) ) }
+
+
+
+
+
+
+ { __( 'How it works', 'jetpack-podcast' ) }
+
+
+ { STEPS.map( step => (
+
+ { step.number }
+ { step.title }
+ { step.body }
+
+ ) ) }
+
+
+
+
+
+);
+
+export default WelcomeTab;
diff --git a/projects/packages/podcast/src/dashboard/topics.ts b/projects/packages/podcast/src/dashboard/topics.ts
new file mode 100644
index 000000000000..c5c5b34ae502
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/topics.ts
@@ -0,0 +1,357 @@
+/**
+ * Apple Podcasts category list, used by the Settings tab.
+ *
+ * Source: https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12. Stored
+ * value format is `"Primary"` for top-level only or `"Primary,Subtopic"` so it
+ * matches what the wpcom mu-plugin and at-pressable-podcasting bridge already
+ * expect when generating tags.
+ *
+ * Resolved once at module load — `_x()` calls are evaluated when this module
+ * is first imported (after `wp-i18n` is initialized via WP's enqueue chain),
+ * so consumers can read TOPICS directly without re-running translations on
+ * every render.
+ *
+ * Each label is a string-literal call to `_x()` because `@wordpress/i18n`'s
+ * extraction tooling parses source statically and can't follow a helper.
+ */
+
+import { _x } from '@wordpress/i18n';
+
+export interface Topic {
+ key: string;
+ label: string;
+ subtopics: Array< { key: string; label: string } >;
+}
+
+export const TOPICS: readonly Topic[] = [
+ {
+ key: 'Arts',
+ label: _x( 'Arts', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Books', label: _x( 'Books', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Design', label: _x( 'Design', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Fashion & Beauty',
+ label: _x( 'Fashion & Beauty', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Food', label: _x( 'Food', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Performing Arts',
+ label: _x( 'Performing Arts', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Visual Arts',
+ label: _x( 'Visual Arts', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Business',
+ label: _x( 'Business', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Careers', label: _x( 'Careers', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Entrepreneurship',
+ label: _x( 'Entrepreneurship', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Investing', label: _x( 'Investing', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Management', label: _x( 'Management', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Marketing', label: _x( 'Marketing', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Non-Profit', label: _x( 'Non-Profit', 'podcasting category', 'jetpack-podcast' ) },
+ ],
+ },
+ {
+ key: 'Comedy',
+ label: _x( 'Comedy', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Comedy Interviews',
+ label: _x( 'Comedy Interviews', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Improv', label: _x( 'Improv', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Stand-Up', label: _x( 'Stand-Up', 'podcasting category', 'jetpack-podcast' ) },
+ ],
+ },
+ {
+ key: 'Education',
+ label: _x( 'Education', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Courses', label: _x( 'Courses', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'How To', label: _x( 'How To', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Language Learning',
+ label: _x( 'Language Learning', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Self-Improvement',
+ label: _x( 'Self-Improvement', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Fiction',
+ label: _x( 'Fiction', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Comedy Fiction',
+ label: _x( 'Comedy Fiction', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Drama', label: _x( 'Drama', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Science Fiction',
+ label: _x( 'Science Fiction', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Government',
+ label: _x( 'Government', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [],
+ },
+ {
+ key: 'History',
+ label: _x( 'History', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [],
+ },
+ {
+ key: 'Health & Fitness',
+ label: _x( 'Health & Fitness', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Alternative Health',
+ label: _x( 'Alternative Health', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Fitness', label: _x( 'Fitness', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Medicine', label: _x( 'Medicine', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Mental Health',
+ label: _x( 'Mental Health', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Nutrition', label: _x( 'Nutrition', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Sexuality', label: _x( 'Sexuality', 'podcasting category', 'jetpack-podcast' ) },
+ ],
+ },
+ {
+ key: 'Kids & Family',
+ label: _x( 'Kids & Family', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Education for Kids',
+ label: _x( 'Education for Kids', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Parenting', label: _x( 'Parenting', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Pets & Animals',
+ label: _x( 'Pets & Animals', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Stories for Kids',
+ label: _x( 'Stories for Kids', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Leisure',
+ label: _x( 'Leisure', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Animation & Manga',
+ label: _x( 'Animation & Manga', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Automotive',
+ label: _x( 'Automotive', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Aviation', label: _x( 'Aviation', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Crafts', label: _x( 'Crafts', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Games', label: _x( 'Games', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Hobbies', label: _x( 'Hobbies', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Home & Garden',
+ label: _x( 'Home & Garden', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Video Games',
+ label: _x( 'Video Games', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Music',
+ label: _x( 'Music', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Music Commentary',
+ label: _x( 'Music Commentary', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Music History',
+ label: _x( 'Music History', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Music Interviews',
+ label: _x( 'Music Interviews', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'News',
+ label: _x( 'News', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Business News',
+ label: _x( 'Business News', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Daily News',
+ label: _x( 'Daily News', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Entertainment News',
+ label: _x( 'Entertainment News', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'News Commentary',
+ label: _x( 'News Commentary', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Politics', label: _x( 'Politics', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Sports News',
+ label: _x( 'Sports News', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Tech News', label: _x( 'Tech News', 'podcasting category', 'jetpack-podcast' ) },
+ ],
+ },
+ {
+ key: 'Religion & Spirituality',
+ label: _x( 'Religion & Spirituality', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Buddhism', label: _x( 'Buddhism', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Christianity',
+ label: _x( 'Christianity', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Hinduism', label: _x( 'Hinduism', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Islam', label: _x( 'Islam', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Judaism', label: _x( 'Judaism', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Religion', label: _x( 'Religion', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Spirituality',
+ label: _x( 'Spirituality', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Science',
+ label: _x( 'Science', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Astronomy', label: _x( 'Astronomy', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Chemistry', label: _x( 'Chemistry', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Earth Sciences',
+ label: _x( 'Earth Sciences', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Life Sciences',
+ label: _x( 'Life Sciences', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Mathematics',
+ label: _x( 'Mathematics', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Natural Sciences',
+ label: _x( 'Natural Sciences', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Nature', label: _x( 'Nature', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Physics', label: _x( 'Physics', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Social Sciences',
+ label: _x( 'Social Sciences', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Society & Culture',
+ label: _x( 'Society & Culture', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'Documentary',
+ label: _x( 'Documentary', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Personal Journals',
+ label: _x( 'Personal Journals', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Philosophy', label: _x( 'Philosophy', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Places & Travel',
+ label: _x( 'Places & Travel', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Relationships',
+ label: _x( 'Relationships', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+ {
+ key: 'Sports',
+ label: _x( 'Sports', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ { key: 'Baseball', label: _x( 'Baseball', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Basketball', label: _x( 'Basketball', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Cricket', label: _x( 'Cricket', 'podcasting category', 'jetpack-podcast' ) },
+ {
+ key: 'Fantasy Sports',
+ label: _x( 'Fantasy Sports', 'podcasting category', 'jetpack-podcast' ),
+ },
+ { key: 'Football', label: _x( 'Football', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Golf', label: _x( 'Golf', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Hockey', label: _x( 'Hockey', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Rugby', label: _x( 'Rugby', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Running', label: _x( 'Running', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Soccer', label: _x( 'Soccer', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Swimming', label: _x( 'Swimming', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Tennis', label: _x( 'Tennis', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Volleyball', label: _x( 'Volleyball', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Wilderness', label: _x( 'Wilderness', 'podcasting category', 'jetpack-podcast' ) },
+ { key: 'Wrestling', label: _x( 'Wrestling', 'podcasting category', 'jetpack-podcast' ) },
+ ],
+ },
+ {
+ key: 'Technology',
+ label: _x( 'Technology', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [],
+ },
+ {
+ key: 'True Crime',
+ label: _x( 'True Crime', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [],
+ },
+ {
+ key: 'TV & Film',
+ label: _x( 'TV & Film', 'podcasting category', 'jetpack-podcast' ),
+ subtopics: [
+ {
+ key: 'After Shows',
+ label: _x( 'After Shows', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Film History',
+ label: _x( 'Film History', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Film Interviews',
+ label: _x( 'Film Interviews', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'Film Reviews',
+ label: _x( 'Film Reviews', 'podcasting category', 'jetpack-podcast' ),
+ },
+ {
+ key: 'TV Reviews',
+ label: _x( 'TV Reviews', 'podcasting category', 'jetpack-podcast' ),
+ },
+ ],
+ },
+];
diff --git a/projects/packages/podcast/src/dashboard/types.ts b/projects/packages/podcast/src/dashboard/types.ts
new file mode 100644
index 000000000000..cf7103acf47e
--- /dev/null
+++ b/projects/packages/podcast/src/dashboard/types.ts
@@ -0,0 +1,87 @@
+/**
+ * Shared types for the Jetpack Podcast SPA.
+ */
+
+export type ExplicitValue = 'no' | 'yes' | 'clean';
+
+export type PodcatcherId =
+ | 'pocketcasts'
+ | 'apple'
+ | 'spotify'
+ | 'youtube'
+ | 'amazon'
+ | 'podcastindex';
+
+export type PodcastShowUrls = Record< PodcatcherId, string >;
+
+export interface PodcastSettings {
+ podcasting_category_id: number;
+ podcasting_title: string;
+ podcasting_talent_name: string;
+ podcasting_summary: string;
+ podcasting_copyright: string;
+ podcasting_explicit: ExplicitValue;
+ podcasting_image: string;
+ podcasting_image_id: number;
+ podcasting_category_1: string;
+ podcasting_category_2: string;
+ podcasting_category_3: string;
+ podcasting_email: string;
+ podcasting_show_urls: PodcastShowUrls;
+}
+
+/**
+ * Shape accepted by `updateSettings()` / `useUpdatePodcastSettings().mutate()`.
+ * All keys are optional. `podcasting_show_urls` is `Partial`
+ * because the server merges a patch into the stored map (so callers can send
+ * `{ apple: 'url' }` without touching `spotify`, etc.). Read responses keep
+ * the full `PodcastShowUrls` shape — the server pads missing keys with `''`.
+ */
+export type PodcastSettingsUpdate = Partial< Omit< PodcastSettings, 'podcasting_show_urls' > > & {
+ podcasting_show_urls?: Partial< PodcastShowUrls >;
+};
+
+export interface PodcastScriptData {
+ categoryId: number;
+ feedUrl: string;
+ siteUrl: string;
+ adminUrl: string;
+ editPostUrlBase: string;
+ newPostUrl: string;
+ mediaLibraryUrl: string;
+ userEmail: string;
+ dateFormat: string;
+}
+
+export interface Episode {
+ id: number;
+ date: string;
+ modified: string;
+ slug: string;
+ status: 'publish' | 'future' | 'draft' | 'pending' | 'private';
+ link: string;
+ title: { rendered: string };
+ excerpt: { rendered: string };
+ featured_media: number;
+ categories: number[];
+ _embedded?: {
+ 'wp:featuredmedia'?: Array< {
+ id: number;
+ source_url: string;
+ media_details?: {
+ sizes?: Record< string, { source_url: string } >;
+ };
+ } >;
+ };
+}
+
+export interface EpisodeStats {
+ post_id: number;
+ duration_seconds: number;
+ plays_all_time: number;
+ plays_7d: number;
+ plays_30d: number;
+ plays_90d: number;
+}
+
+export type TabName = 'welcome' | 'settings' | 'episodes' | 'distribution';
diff --git a/projects/packages/podcast/src/feed/class-app-detection.php b/projects/packages/podcast/src/feed/class-app-detection.php
new file mode 100644
index 000000000000..07e14da2fbdf
--- /dev/null
+++ b/projects/packages/podcast/src/feed/class-app-detection.php
@@ -0,0 +1,77 @@
+
+ */
+ private const NEEDLES = array(
+ 'iTMS' => 'apple',
+ 'AppleCoreMedia' => 'apple',
+ 'Podcasts/' => 'apple',
+ 'iTunes' => 'apple',
+ 'Spotify' => 'spotify',
+ 'Pocket Casts' => 'pocketcasts',
+ 'PocketCasts' => 'pocketcasts',
+ 'AmazonMusic' => 'amazon',
+ 'Podcastindex.org' => 'podcastindex',
+ // YouTube Music podcasts hasn't published an official UA — using the
+ // legacy Google Podcasts crawler until we observe a stable signature.
+ 'Google-Podcast' => 'youtube',
+ 'YouTube-Podcast' => 'youtube',
+ 'Overcast' => 'overcast',
+ 'Podcast Addict' => 'podcast-addict',
+ 'CastBox' => 'castbox',
+ 'Castro' => 'castro',
+ );
+
+ /**
+ * Return the matching slug for a UA, or null if nothing matches.
+ *
+ * @param string $ua Raw User-Agent header.
+ * @return string|null
+ */
+ public static function detect_slug( $ua ) {
+ if ( ! is_string( $ua ) || '' === $ua ) {
+ return null;
+ }
+ foreach ( self::NEEDLES as $needle => $slug ) {
+ if ( false !== stripos( $ua, $needle ) ) {
+ return $slug;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * True for the standard browser tokens. Used by play-tracking elsewhere;
+ * the feed detector doesn't care about web browsers.
+ *
+ * @param string $ua Raw User-Agent header.
+ * @return bool
+ */
+ public static function is_web_browser( $ua ) {
+ if ( ! is_string( $ua ) || '' === $ua ) {
+ return false;
+ }
+ return (bool) preg_match( '#Mozilla/|Chrome/|Safari/|Firefox/#', $ua );
+ }
+}
diff --git a/projects/packages/podcast/src/feed/class-customize-feed.php b/projects/packages/podcast/src/feed/class-customize-feed.php
new file mode 100644
index 000000000000..4bac4bcbe288
--- /dev/null
+++ b/projects/packages/podcast/src/feed/class-customize-feed.php
@@ -0,0 +1,268 @@
+name;
+ }
+
+ /**
+ * Override the RSS feed description with the podcast summary.
+ *
+ * @param string $value Default value.
+ * @param string $field Bloginfo field name.
+ * @return string
+ */
+ public static function filter_feed_description( $value, $field ) {
+ if ( 'description' !== $field ) {
+ return $value;
+ }
+ return (string) get_option( 'podcasting_summary', '' );
+ }
+
+ /**
+ * Render channel-level iTunes / Google Play metadata.
+ */
+ public static function render_feed_head() {
+ $summary = get_option( 'podcasting_summary' );
+ if ( ! empty( $summary ) ) {
+ $summary = wp_strip_all_tags( $summary );
+ echo '' . esc_html( $summary ) . " \n";
+ echo '' . esc_html( $summary ) . " \n";
+ }
+
+ $author = get_option( 'podcasting_talent_name' );
+ if ( ! empty( $author ) ) {
+ $author = wp_strip_all_tags( $author );
+ echo '' . esc_html( $author ) . " \n";
+ echo '' . esc_html( $author ) . " \n";
+ }
+
+ $email = get_option( 'podcasting_email' );
+ if ( ! empty( $email ) ) {
+ $email = wp_strip_all_tags( $email );
+ echo "\n";
+ echo "\t" . esc_html( $email ) . " \n";
+ echo " \n";
+ echo '' . esc_html( $email ) . " \n";
+ echo '' . esc_html( $email ) . " \n";
+ }
+
+ $copyright = get_option( 'podcasting_copyright' );
+ if ( ! empty( $copyright ) ) {
+ echo '' . esc_html( wp_strip_all_tags( $copyright ) ) . " \n";
+ }
+
+ // 'yes' is the only value that flips explicit to true; both 'no' and 'clean' are not-explicit.
+ $explicit = 'yes' === get_option( 'podcasting_explicit', 'no' ) ? 'true' : 'false';
+ echo '' . esc_html( $explicit ) . " \n";
+ echo '' . esc_html( $explicit ) . " \n";
+
+ $image = Podcast::get_image_url();
+ if ( ! empty( $image ) ) {
+ if ( function_exists( 'jetpack_photon_url' ) ) {
+ // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only helper; guarded above.
+ $image = jetpack_photon_url( $image, array( 'fit' => '3000,3000' ), 'https' );
+ }
+ echo " \n";
+ echo " \n";
+ }
+
+ foreach ( array( 'podcasting_category_1', 'podcasting_category_2', 'podcasting_category_3' ) as $option ) {
+ $tag = self::generate_category_tag( $option );
+ if ( ! empty( $tag ) ) {
+ echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- generate_category_tag escapes its inputs.
+ }
+ }
+ }
+
+ /**
+ * Render per-episode iTunes / Google Play metadata inside the rss2 item.
+ */
+ public static function render_feed_item() {
+ global $post;
+
+ $author = get_the_author();
+ if ( empty( $author ) ) {
+ $author = get_option( 'podcasting_talent_name' );
+ }
+ $author = wp_strip_all_tags( $author );
+ echo '' . esc_html( $author ) . " \n";
+ echo '' . esc_html( $author ) . " \n";
+
+ $explicit = 'yes' === get_option( 'podcasting_explicit', 'no' ) ? 'true' : 'false';
+ echo '' . esc_html( $explicit ) . " \n";
+ echo '' . esc_html( $explicit ) . " \n";
+
+ if ( $post && has_post_thumbnail( $post->ID ) ) {
+ $image_src = wp_get_attachment_image_src( get_post_thumbnail_id( $post->ID ), 'full' );
+ if ( ! empty( $image_src ) && is_array( $image_src ) ) {
+ $image = $image_src[0];
+ if ( function_exists( 'jetpack_photon_url' ) ) {
+ // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only helper; guarded above.
+ $image = jetpack_photon_url( $image, array( 'fit' => '3000,3000' ), 'https' );
+ }
+ echo " \n";
+ echo " \n";
+ }
+ }
+
+ $excerpt = apply_filters( 'the_excerpt_rss', get_the_excerpt() );
+ $excerpt = wp_strip_all_tags( $excerpt );
+ echo '' . esc_html( $excerpt ) . " \n";
+ echo '' . esc_html( $excerpt ) . " \n";
+ }
+
+ /**
+ * Append `` to each RSS enclosure when attachment metadata is available.
+ *
+ * @param string $enclosure Default WP-rendered enclosure tag.
+ * @return string
+ */
+ public static function filter_rss_enclosure( $enclosure ) {
+ preg_match( '/url="([^"]*)"/i', $enclosure, $matches );
+ if ( empty( $matches ) ) {
+ return $enclosure;
+ }
+
+ $attachment_id = attachment_url_to_postid( $matches[1] );
+ if ( 0 === $attachment_id ) {
+ return $enclosure;
+ }
+
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+ $duration = absint( $metadata['length'] ?? 0 );
+ if ( 0 === $duration ) {
+ return $enclosure;
+ }
+
+ return $enclosure . '' . $duration . " \n";
+ }
+
+ /**
+ * Suppress the standard "[...]" placeholder when an episode has no excerpt.
+ *
+ * Hooked at priority 1000 so it runs after any other filter that may have inserted text.
+ *
+ * @param string $output Output from earlier filters.
+ * @return string
+ */
+ public static function filter_empty_rss_excerpt( $output ) {
+ $excerpt = get_the_excerpt();
+ return empty( $excerpt ) ? '' : $output;
+ }
+
+ /**
+ * Convert a stored "Primary,Sub" category option into nested itunes:category tags.
+ *
+ * Includes a few legacy normalizations for category names that have changed in iTunes.
+ *
+ * @param string $option Option name (e.g. 'podcasting_category_1').
+ * @return string Rendered tag(s) or an empty string.
+ */
+ private static function generate_category_tag( $option ) {
+ $category = get_option( $option );
+ if ( empty( $category ) ) {
+ return '';
+ }
+
+ // Normalize a few legacy iTunes category names.
+ $legacy_aliases = array(
+ 'Education,Education' => 'Education',
+ 'Education,Education Technology' => 'Education, Educational Technology',
+ 'Tech News' => 'Technology,Tech News',
+ 'Sports & Recreation,Technology' => 'Technology',
+ 'Sports & Recreation,Gadgets' => 'Technology,Gadgets',
+ );
+ if ( isset( $legacy_aliases[ $category ] ) ) {
+ $category = $legacy_aliases[ $category ];
+ }
+
+ $splits = explode( ',', $category );
+ if ( 2 === count( $splits ) ) {
+ $out = "\n";
+ $out .= "\t \n";
+ $out .= " \n";
+ return $out;
+ }
+
+ return " \n";
+ }
+}
diff --git a/projects/packages/podcast/src/feed/class-feed-detection.php b/projects/packages/podcast/src/feed/class-feed-detection.php
new file mode 100644
index 000000000000..05dc6b2b1992
--- /dev/null
+++ b/projects/packages/podcast/src/feed/class-feed-detection.php
@@ -0,0 +1,90 @@
+
+ */
+ private const TRACKED_SLUGS = array( 'apple', 'spotify', 'pocketcasts', 'amazon', 'podcastindex', 'youtube' );
+
+ /**
+ * Read the UA, match it against the tracked slugs, mark active in the
+ * `podcasting_show_states` option on first sighting. Wrapped in a
+ * try/catch so a misbehaving option store can never fatal the feed
+ * response itself.
+ */
+ public static function detect_and_record() {
+ try {
+ $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
+ ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
+ : '';
+ if ( '' === $ua ) {
+ return;
+ }
+
+ $slug = self::match_podcatcher( $ua );
+ if ( null === $slug ) {
+ return;
+ }
+
+ $states = get_option( 'podcasting_show_states', array() );
+ if ( ! is_array( $states ) ) {
+ $states = array();
+ }
+ if ( isset( $states[ $slug ] ) && 'active' === $states[ $slug ] ) {
+ return;
+ }
+
+ // Concurrent first-time fetches from different apps can race the
+ // read-modify-write and clobber each other's key. Benign: the next
+ // poll from the losing app (apps poll on a multi-hour cadence)
+ // hits this same path and re-writes its key. Worst-case UI cost is
+ // one app sitting in `pending` for a poll cycle.
+ $states[ $slug ] = 'active';
+ update_option( 'podcasting_show_states', $states );
+ } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Detection is best-effort. Logging would require a wpcom-only
+ // helper; on Atomic we'd need a different transport. Swallow
+ // silently here — if we ever wire in a Jetpack-side logger,
+ // emit an event from this branch.
+ }
+ }
+
+ /**
+ * Resolve a UA to one of TRACKED_SLUGS, or null.
+ *
+ * @param string $ua Raw User-Agent header.
+ * @return string|null
+ */
+ private static function match_podcatcher( $ua ) {
+ $slug = App_Detection::detect_slug( $ua );
+ if ( null === $slug || ! in_array( $slug, self::TRACKED_SLUGS, true ) ) {
+ return null;
+ }
+ return $slug;
+ }
+}
diff --git a/projects/packages/podcast/src/rest/class-settings-rest.php b/projects/packages/podcast/src/rest/class-settings-rest.php
new file mode 100644
index 000000000000..fea952a48524
--- /dev/null
+++ b/projects/packages/podcast/src/rest/class-settings-rest.php
@@ -0,0 +1,403 @@
+}>
+ */
+ private const SETTINGS = array(
+ 'podcasting_category_id' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ 'podcasting_title' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_talent_name' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_summary' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_copyright' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_explicit' => array(
+ 'type' => 'string',
+ 'default' => 'no',
+ 'enum' => array( 'no', 'yes', 'clean' ),
+ ),
+ 'podcasting_image' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_image_id' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ 'podcasting_category_1' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_category_2' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_category_3' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'podcasting_email' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ );
+
+ /**
+ * Per-podcatcher host allowlist for `podcasting_show_urls`.
+ *
+ * Mirrors the wpcom mu-plugin's allowlist
+ * (`Automattic_Podcasting_Settings_REST_API::SHOW_URL_HOSTS`) so the same
+ * URL passes validation on both hosts. Hostnames are lowercased and
+ * `www.` is stripped before comparison. Keep this in sync with the
+ * directory IDs in `src/dashboard/tabs/distribution.tsx`.
+ *
+ * @var array>
+ */
+ private const SHOW_URL_HOSTS = array(
+ 'pocketcasts' => array( 'pca.st', 'pocketcasts.com' ),
+ 'apple' => array( 'podcasts.apple.com' ),
+ 'spotify' => array( 'open.spotify.com' ),
+ 'youtube' => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ),
+ 'amazon' => array( 'music.amazon.com', 'music.amazon.co.uk', 'music.amazon.de', 'music.amazon.co.jp', 'music.amazon.com.au', 'music.amazon.fr', 'music.amazon.ca', 'music.amazon.es' ),
+ 'podcastindex' => array( 'podcastindex.org' ),
+ );
+
+ private const SHOW_URL_MAX_LENGTH = 2048;
+
+ /**
+ * Whether hooks have been registered.
+ *
+ * @var bool
+ */
+ private static $initialized = false;
+
+ /**
+ * Register hooks.
+ *
+ * `register_setting()` is always installed — it's the canonical entry point
+ * for `/wp/v2/settings` on Atomic and standard WP, and nothing else (legacy
+ * code included) puts the `podcasting_*` keys there. The wpcom site-settings
+ * filters, on the other hand, do overlap with the legacy mu-plugin / bridge
+ * when both are active, so we skip them in that case.
+ *
+ * @param bool $register_wpcom_filters Whether to register the
+ * `site_settings_endpoint_get` /
+ * `rest_api_update_site_settings`
+ * filters. Pass `false` when legacy
+ * code is active to avoid running
+ * equivalent logic twice.
+ */
+ public static function init( $register_wpcom_filters = true ) {
+ if ( self::$initialized ) {
+ return;
+ }
+ self::$initialized = true;
+
+ // register_setting() is only consumed by the /wp/v2/settings REST controller
+ // during REST requests, so registering anywhere else (e.g. on `init`) just
+ // burns cycles on every frontend pageload.
+ add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
+
+ if ( $register_wpcom_filters ) {
+ add_filter( 'site_settings_endpoint_get', array( __CLASS__, 'handle_wpcom_get' ) );
+ add_filter(
+ 'rest_api_update_site_settings',
+ array( __CLASS__, 'handle_wpcom_update' ),
+ 10,
+ 2
+ );
+ }
+ }
+
+ /**
+ * Register podcasting options with the WP settings registry so they appear
+ * in `/wp/v2/settings` on standard WP installs and Atomic.
+ */
+ public static function register_settings() {
+ foreach ( self::SETTINGS as $option => $schema ) {
+ $args = array(
+ 'type' => $schema['type'],
+ 'default' => $schema['default'],
+ 'show_in_rest' => array(
+ 'name' => $option,
+ 'schema' => array(
+ 'type' => $schema['type'],
+ ),
+ ),
+ );
+
+ if ( isset( $schema['enum'] ) ) {
+ $args['show_in_rest']['schema']['enum'] = $schema['enum'];
+ }
+
+ register_setting( 'general', $option, $args );
+ }
+
+ // Object-typed setting: per-podcatcher show URLs. Schema enumerates the
+ // known directory keys; sanitize_callback enforces the host allowlist
+ // and merges with the existing stored value (so partial patches don't
+ // blow away other directories' URLs on /wp/v2/settings, which doesn't
+ // merge by default).
+ $show_url_properties = array();
+ foreach ( array_keys( self::SHOW_URL_HOSTS ) as $key ) {
+ $show_url_properties[ $key ] = array( 'type' => 'string' );
+ }
+
+ register_setting(
+ 'general',
+ 'podcasting_show_urls',
+ array(
+ 'type' => 'object',
+ 'default' => array(),
+ 'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
+ 'show_in_rest' => array(
+ 'name' => 'podcasting_show_urls',
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => $show_url_properties,
+ 'additionalProperties' => false,
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Inject podcasting settings into the wpcom site-settings GET response (Simple sites).
+ *
+ * @param array $settings Existing settings array.
+ * @return array
+ */
+ public static function handle_wpcom_get( $settings ) {
+ $settings['podcasting_category_id'] = (int) Podcast::get_category_id();
+ $settings['podcasting_image'] = (string) Podcast::get_image_url();
+ $settings['podcasting_image_id'] = (int) get_option( 'podcasting_image_id', 0 );
+
+ foreach ( self::SETTINGS as $option => $schema ) {
+ if ( isset( $settings[ $option ] ) ) {
+ continue;
+ }
+ $value = get_option( $option, $schema['default'] );
+ $settings[ $option ] = 'integer' === $schema['type'] ? (int) $value : (string) $value;
+ }
+
+ if ( ! isset( $settings['podcasting_show_urls'] ) ) {
+ $settings['podcasting_show_urls'] = self::get_show_urls();
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Cast podcasting settings on incoming wpcom site-settings POST requests (Simple sites).
+ *
+ * @param array $original_input Already-sanitized input array.
+ * @param array $unfiltered_input Raw client input.
+ * @return array
+ */
+ public static function handle_wpcom_update( $original_input, $unfiltered_input ) {
+ $output = (array) $original_input;
+
+ foreach ( self::SETTINGS as $option => $schema ) {
+ if ( ! isset( $unfiltered_input[ $option ] ) ) {
+ continue;
+ }
+
+ if ( 'integer' === $schema['type'] ) {
+ $output[ $option ] = (int) $unfiltered_input[ $option ];
+ continue;
+ }
+
+ $value = (string) $unfiltered_input[ $option ];
+ if ( isset( $schema['enum'] ) && ! in_array( $value, $schema['enum'], true ) ) {
+ $value = (string) $schema['default'];
+ }
+ $output[ $option ] = $value;
+ }
+
+ if ( isset( $unfiltered_input['podcasting_show_urls'] ) ) {
+ $merged = self::merge_show_urls( $unfiltered_input['podcasting_show_urls'] );
+ if ( null !== $merged ) {
+ $output['podcasting_show_urls'] = $merged;
+ } else {
+ // Nothing valid to apply — let the existing stored value stand.
+ unset( $output['podcasting_show_urls'] );
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Read the stored show URLs and pad with empty strings for every known
+ * podcatcher so the response always has a stable shape.
+ *
+ * @return array
+ */
+ public static function get_show_urls() {
+ $stored = get_option( 'podcasting_show_urls', array() );
+ if ( ! is_array( $stored ) ) {
+ $stored = array();
+ }
+
+ $urls = array();
+ foreach ( array_keys( self::SHOW_URL_HOSTS ) as $key ) {
+ $urls[ $key ] = isset( $stored[ $key ] ) && is_string( $stored[ $key ] ) ? $stored[ $key ] : '';
+ }
+
+ return $urls;
+ }
+
+ /**
+ * `register_setting()` sanitize callback for `podcasting_show_urls`.
+ *
+ * `/wp/v2/settings` writes the value WP-side via `update_option`, which
+ * doesn't merge — it replaces. So we read the current option, merge the
+ * incoming patch into it, and return the merged array. That way a client
+ * sending `{ apple: 'url' }` doesn't wipe out `spotify`.
+ *
+ * @param mixed $input Incoming value from the REST request.
+ * @return array
+ */
+ public static function sanitize_show_urls( $input ) {
+ $merged = self::merge_show_urls( $input );
+ if ( null !== $merged ) {
+ return $merged;
+ }
+
+ $stored = get_option( 'podcasting_show_urls', array() );
+ return is_array( $stored ) ? $stored : array();
+ }
+
+ /**
+ * Merge a partial show_urls patch into the currently stored value.
+ *
+ * Empty string for a known key removes that entry. Unknown keys and URLs
+ * that don't match the per-podcatcher host allowlist are dropped silently.
+ * Returns the merged array, or null when the input is unusable / produces
+ * no effective change.
+ *
+ * @param mixed $input Incoming patch.
+ * @return array|null
+ */
+ private static function merge_show_urls( $input ) {
+ if ( ! is_array( $input ) ) {
+ return null;
+ }
+
+ // Strip the empty padding handle_get() adds so we only persist real entries.
+ $current = array_filter(
+ self::get_show_urls(),
+ static function ( $value ) {
+ return is_string( $value ) && '' !== $value;
+ }
+ );
+
+ $changed = false;
+
+ foreach ( $input as $key => $value ) {
+ if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
+ continue;
+ }
+
+ $value = is_string( $value ) ? trim( $value ) : '';
+
+ if ( '' === $value ) {
+ if ( isset( $current[ $key ] ) ) {
+ unset( $current[ $key ] );
+ $changed = true;
+ }
+ continue;
+ }
+
+ $cleaned = self::validate_show_url( $key, $value );
+ if ( null === $cleaned ) {
+ continue;
+ }
+
+ if ( ! isset( $current[ $key ] ) || $current[ $key ] !== $cleaned ) {
+ $current[ $key ] = $cleaned;
+ $changed = true;
+ }
+ }
+
+ return $changed ? $current : null;
+ }
+
+ /**
+ * Validate a URL against the host allowlist for a given podcatcher key.
+ *
+ * @param string $key Directory ID (e.g. 'apple').
+ * @param mixed $url Candidate URL.
+ * @return string|null Cleaned URL on success, null on failure.
+ */
+ private static function validate_show_url( $key, $url ) {
+ if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
+ return null;
+ }
+
+ if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
+ return null;
+ }
+
+ $cleaned = esc_url_raw( $url, array( 'https' ) );
+ if ( '' === $cleaned ) {
+ return null;
+ }
+
+ if ( ! wp_http_validate_url( $cleaned ) ) {
+ return null;
+ }
+
+ $host = wp_parse_url( $cleaned, PHP_URL_HOST );
+ if ( ! is_string( $host ) || '' === $host ) {
+ return null;
+ }
+
+ $host = strtolower( $host );
+ if ( 0 === strpos( $host, 'www.' ) ) {
+ $host = substr( $host, 4 );
+ }
+
+ return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
+ }
+}
diff --git a/projects/packages/podcast/tests/.phpcs.dir.xml b/projects/packages/podcast/tests/.phpcs.dir.xml
new file mode 100644
index 000000000000..46951fe77b37
--- /dev/null
+++ b/projects/packages/podcast/tests/.phpcs.dir.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/projects/packages/podcast/tests/php/Podcast_Test.php b/projects/packages/podcast/tests/php/Podcast_Test.php
new file mode 100644
index 000000000000..ab365f6d76a7
--- /dev/null
+++ b/projects/packages/podcast/tests/php/Podcast_Test.php
@@ -0,0 +1,61 @@
+assertFalse( Podcast::is_enabled() );
+ }
+
+ /**
+ * `get_category_id()` returns false when neither the integer option nor the
+ * legacy slug option is set.
+ */
+ public function test_get_category_id_is_false_when_unset() {
+ $this->assertFalse( Podcast::get_category_id() );
+ }
+
+ /**
+ * `get_image_url()` falls back to the URL option when no attachment is set.
+ */
+ public function test_get_image_url_falls_back_to_url_option() {
+ $this->assertSame( '', Podcast::get_image_url() );
+
+ update_option( 'podcasting_image', 'https://example.com/cover.jpg' );
+ $this->assertSame( 'https://example.com/cover.jpg', Podcast::get_image_url() );
+ }
+}
diff --git a/projects/packages/podcast/tests/php/bootstrap.php b/projects/packages/podcast/tests/php/bootstrap.php
new file mode 100644
index 000000000000..fe7ccf4c45ad
--- /dev/null
+++ b/projects/packages/podcast/tests/php/bootstrap.php
@@ -0,0 +1,12 @@
+=7.2"
+ },
+ "require-dev": {
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev",
+ "yoast/phpunit-polyfills": "^4.0.0"
+ },
+ "type": "jetpack-library",
+ "extra": {
+ "autorelease": true,
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-podcast",
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "textdomain": "jetpack-podcast",
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-podcast.php"
+ },
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "build-development": [
+ "pnpm run build"
+ ],
+ "build-production": [
+ "pnpm run build-production"
+ ],
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ],
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "typecheck": [
+ "pnpm run typecheck"
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Jetpack Podcast functionality (Simple and Atomic only).",
+ "transport-options": {
+ "relative": true
+ }
+ },
{
"name": "automattic/jetpack-post-list",
"version": "dev-trunk",
@@ -5844,6 +5913,7 @@
"automattic/jetpack-newsletter": 20,
"automattic/jetpack-paypal-payments": 20,
"automattic/jetpack-plugins-installer": 20,
+ "automattic/jetpack-podcast": 20,
"automattic/jetpack-post-list": 20,
"automattic/jetpack-post-media": 20,
"automattic/jetpack-publicize": 20,
diff --git a/projects/plugins/jetpack/load-jetpack.php b/projects/plugins/jetpack/load-jetpack.php
index 57cf7abfbebc..2bd735d48eac 100644
--- a/projects/plugins/jetpack/load-jetpack.php
+++ b/projects/plugins/jetpack/load-jetpack.php
@@ -71,6 +71,10 @@ function jetpack_should_use_minified_assets() {
\Automattic\Jetpack\Plugin\Jetpack_Script_Data::configure();
}
+// Initialize Podcast (Simple + Atomic only). Loaded on both admin and frontend so the
+// feed customization, REST settings, and admin page all work from the same entry point.
+\Automattic\Jetpack\Podcast\Podcast::init();
+
// Play nice with https://wp-cli.org/.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once JETPACK__PLUGIN_DIR . 'class.jetpack-cli.php';
diff --git a/projects/plugins/mu-wpcom-plugin/changelog/wire-podcast-package b/projects/plugins/mu-wpcom-plugin/changelog/wire-podcast-package
new file mode 100644
index 000000000000..66955e447a9f
--- /dev/null
+++ b/projects/plugins/mu-wpcom-plugin/changelog/wire-podcast-package
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Updated composer.lock to include the new jetpack-podcast package transitively.
diff --git a/projects/plugins/mu-wpcom-plugin/composer.lock b/projects/plugins/mu-wpcom-plugin/composer.lock
index 5a5d27cea8e2..3d04f04fe0fb 100644
--- a/projects/plugins/mu-wpcom-plugin/composer.lock
+++ b/projects/plugins/mu-wpcom-plugin/composer.lock
@@ -1227,7 +1227,7 @@
"dist": {
"type": "path",
"url": "../../packages/jetpack-mu-wpcom",
- "reference": "8ccf42e3bb801d5edfc343c3648afe1976f7d6fc"
+ "reference": "5ac3b4268d352de7e901a9ce29ebab28ce26b950"
},
"require": {
"automattic/block-delimiter": "@dev",
@@ -1240,6 +1240,7 @@
"automattic/jetpack-google-analytics": "@dev",
"automattic/jetpack-masterbar": "@dev",
"automattic/jetpack-newsletter": "@dev",
+ "automattic/jetpack-podcast": "@dev",
"automattic/jetpack-redirect": "@dev",
"automattic/jetpack-rtc": "@dev",
"automattic/jetpack-stats-admin": "@dev",
@@ -1503,6 +1504,75 @@
"relative": true
}
},
+ {
+ "name": "automattic/jetpack-podcast",
+ "version": "dev-trunk",
+ "dist": {
+ "type": "path",
+ "url": "../../packages/podcast",
+ "reference": "ce1f374b27b71a5a2a3fa891fa9e0ab1ce2db0c2"
+ },
+ "require": {
+ "automattic/jetpack-admin-ui": "@dev",
+ "automattic/jetpack-assets": "@dev",
+ "automattic/jetpack-status": "@dev",
+ "php": ">=7.2"
+ },
+ "require-dev": {
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev",
+ "yoast/phpunit-polyfills": "^4.0.0"
+ },
+ "type": "jetpack-library",
+ "extra": {
+ "autorelease": true,
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-podcast",
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "textdomain": "jetpack-podcast",
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-podcast.php"
+ },
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "build-development": [
+ "pnpm run build"
+ ],
+ "build-production": [
+ "pnpm run build-production"
+ ],
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ],
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "typecheck": [
+ "pnpm run typecheck"
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Jetpack Podcast functionality (Simple and Atomic only).",
+ "transport-options": {
+ "relative": true
+ }
+ },
{
"name": "automattic/jetpack-post-media",
"version": "dev-trunk",
@@ -2643,16 +2713,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v6.4.34",
+ "version": "v6.4.37",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3"
+ "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/29f792d7dc30cc670fc4cdd50d7c6653d067ce7b",
+ "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b",
"shasum": ""
},
"require": {
@@ -2689,7 +2759,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v6.4.34"
+ "source": "https://github.com/symfony/filesystem/tree/v6.4.37"
},
"funding": [
{
@@ -2709,11 +2779,11 @@
"type": "tidelift"
}
],
- "time": "2026-02-24T17:51:06+00:00"
+ "time": "2026-04-13T15:27:04+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -2772,7 +2842,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -2796,7 +2866,7 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -2857,7 +2927,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -3180,16 +3250,16 @@
},
{
"name": "phpunit/php-code-coverage",
- "version": "12.5.5",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "a25bde1f8f83849f441ef5713c6466e470872a71"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a25bde1f8f83849f441ef5713c6466e470872a71",
- "reference": "a25bde1f8f83849f441ef5713c6466e470872a71",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
@@ -3244,7 +3314,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.5"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
@@ -3264,7 +3334,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T04:53:32+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -3525,16 +3595,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.19",
+ "version": "12.5.24",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da"
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/92f7744ca5f5701c9e4b4a60d9e143f2d84956da",
- "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046",
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046",
"shasum": ""
},
"require": {
@@ -3548,15 +3618,15 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.5",
+ "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.0",
- "sebastian/comparator": "^7.1.5",
+ "sebastian/comparator": "^7.1.6",
"sebastian/diff": "^7.0.0",
- "sebastian/environment": "^8.0.4",
+ "sebastian/environment": "^8.1.0",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -3603,7 +3673,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.19"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24"
},
"funding": [
{
@@ -3611,7 +3681,7 @@
"type": "other"
}
],
- "time": "2026-04-13T05:38:19+00:00"
+ "time": "2026-05-01T04:21:04+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -3684,16 +3754,16 @@
},
{
"name": "sebastian/comparator",
- "version": "7.1.5",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
@@ -3752,7 +3822,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
},
"funding": [
{
@@ -3772,7 +3842,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-08T04:43:00+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -3901,16 +3971,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.4",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
@@ -3925,7 +3995,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -3953,7 +4023,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
},
"funding": [
{
@@ -3973,7 +4043,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-15T07:05:40+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
diff --git a/projects/plugins/wpcomsh/changelog/wire-podcast-package b/projects/plugins/wpcomsh/changelog/wire-podcast-package
new file mode 100644
index 000000000000..66955e447a9f
--- /dev/null
+++ b/projects/plugins/wpcomsh/changelog/wire-podcast-package
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Updated composer.lock to include the new jetpack-podcast package transitively.
diff --git a/projects/plugins/wpcomsh/composer.lock b/projects/plugins/wpcomsh/composer.lock
index 5183d0eb8e10..741a32444d4c 100644
--- a/projects/plugins/wpcomsh/composer.lock
+++ b/projects/plugins/wpcomsh/composer.lock
@@ -1434,7 +1434,7 @@
"dist": {
"type": "path",
"url": "../../packages/jetpack-mu-wpcom",
- "reference": "8ccf42e3bb801d5edfc343c3648afe1976f7d6fc"
+ "reference": "5ac3b4268d352de7e901a9ce29ebab28ce26b950"
},
"require": {
"automattic/block-delimiter": "@dev",
@@ -1447,6 +1447,7 @@
"automattic/jetpack-google-analytics": "@dev",
"automattic/jetpack-masterbar": "@dev",
"automattic/jetpack-newsletter": "@dev",
+ "automattic/jetpack-podcast": "@dev",
"automattic/jetpack-redirect": "@dev",
"automattic/jetpack-rtc": "@dev",
"automattic/jetpack-stats-admin": "@dev",
@@ -1710,6 +1711,75 @@
"relative": true
}
},
+ {
+ "name": "automattic/jetpack-podcast",
+ "version": "dev-trunk",
+ "dist": {
+ "type": "path",
+ "url": "../../packages/podcast",
+ "reference": "ce1f374b27b71a5a2a3fa891fa9e0ab1ce2db0c2"
+ },
+ "require": {
+ "automattic/jetpack-admin-ui": "@dev",
+ "automattic/jetpack-assets": "@dev",
+ "automattic/jetpack-status": "@dev",
+ "php": ">=7.2"
+ },
+ "require-dev": {
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev",
+ "yoast/phpunit-polyfills": "^4.0.0"
+ },
+ "type": "jetpack-library",
+ "extra": {
+ "autorelease": true,
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-podcast",
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "textdomain": "jetpack-podcast",
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-podcast.php"
+ },
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "build-development": [
+ "pnpm run build"
+ ],
+ "build-production": [
+ "pnpm run build-production"
+ ],
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ],
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "typecheck": [
+ "pnpm run typecheck"
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Jetpack Podcast functionality (Simple and Atomic only).",
+ "transport-options": {
+ "relative": true
+ }
+ },
{
"name": "automattic/jetpack-post-list",
"version": "dev-trunk",
@@ -2990,16 +3060,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v6.4.34",
+ "version": "v6.4.37",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3"
+ "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/29f792d7dc30cc670fc4cdd50d7c6653d067ce7b",
+ "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b",
"shasum": ""
},
"require": {
@@ -3036,7 +3106,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v6.4.34"
+ "source": "https://github.com/symfony/filesystem/tree/v6.4.37"
},
"funding": [
{
@@ -3056,11 +3126,11 @@
"type": "tidelift"
}
],
- "time": "2026-02-24T17:51:06+00:00"
+ "time": "2026-04-13T15:27:04+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -3119,7 +3189,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -3143,7 +3213,7 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -3204,7 +3274,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -4049,16 +4119,16 @@
},
{
"name": "phpunit/php-code-coverage",
- "version": "12.5.5",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "a25bde1f8f83849f441ef5713c6466e470872a71"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a25bde1f8f83849f441ef5713c6466e470872a71",
- "reference": "a25bde1f8f83849f441ef5713c6466e470872a71",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
@@ -4113,7 +4183,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.5"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
@@ -4133,7 +4203,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T04:53:32+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -4394,16 +4464,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.19",
+ "version": "12.5.24",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da"
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/92f7744ca5f5701c9e4b4a60d9e143f2d84956da",
- "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046",
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046",
"shasum": ""
},
"require": {
@@ -4417,15 +4487,15 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.5",
+ "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.0",
- "sebastian/comparator": "^7.1.5",
+ "sebastian/comparator": "^7.1.6",
"sebastian/diff": "^7.0.0",
- "sebastian/environment": "^8.0.4",
+ "sebastian/environment": "^8.1.0",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -4472,7 +4542,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.19"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24"
},
"funding": [
{
@@ -4480,7 +4550,7 @@
"type": "other"
}
],
- "time": "2026-04-13T05:38:19+00:00"
+ "time": "2026-05-01T04:21:04+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -4553,16 +4623,16 @@
},
{
"name": "sebastian/comparator",
- "version": "7.1.5",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
@@ -4621,7 +4691,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
},
"funding": [
{
@@ -4641,7 +4711,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-08T04:43:00+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -4770,16 +4840,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.4",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
@@ -4794,7 +4864,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -4822,7 +4892,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
},
"funding": [
{
@@ -4842,7 +4912,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-15T07:05:40+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",