diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index fb65aa7ecf57de..c5a59000822be6 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -543,6 +543,28 @@ Show a block pattern. ([Source](https://github.com/WordPress/gutenberg/tree/trun
- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~
- **Attributes:** slug
+## Playlist
+
+Embed a simple playlist. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/playlist))
+
+- **Name:** core/playlist
+- **Experimental:** true
+- **Category:** media
+- **Allowed Blocks:** core/playlist-track
+- **Supports:** align, anchor, color (background, gradients, link, text), interactivity, spacing (margin, padding)
+- **Attributes:** caption, currentTrack, order, showArtists, showImages, showNumbers, showTracklist, type
+
+## Playlist track
+
+Playlist track. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/playlist-track))
+
+- **Name:** core/playlist-track
+- **Experimental:** true
+- **Category:** media
+- **Parent:** core/playlist
+- **Supports:** interactivity (clientNavigation), ~~html~~, ~~reusable~~
+- **Attributes:** album, artist, blob, id, image, length, src, title, type, uniqueId
+
## Author
Display post author details such as name, avatar, and bio. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/post-author))
diff --git a/lib/blocks.php b/lib/blocks.php
index 342cd25191e689..e1c3d21dd4616f 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -31,6 +31,8 @@ function gutenberg_reregister_core_block_types() {
'more',
'nextpage',
'paragraph',
+ 'playlist',
+ 'playlist-track',
'preformatted',
'pullquote',
'quote',
@@ -88,6 +90,8 @@ function gutenberg_reregister_core_block_types() {
'post-author.php' => 'core/post-author',
'post-author-name.php' => 'core/post-author-name',
'post-author-biography.php' => 'core/post-author-biography',
+ 'playlist.php' => 'core/playlist',
+ 'playlist-track.php' => 'core/playlist-track',
'post-comment.php' => 'core/post-comment',
'post-comments-count.php' => 'core/post-comments-count',
'post-comments-form.php' => 'core/post-comments-form',
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 70d575e6a5a6dd..9d996092549972 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -31,6 +31,7 @@
"./form/view": "./build-module/form/view.js",
"./image/view": "./build-module/image/view.js",
"./navigation/view": "./build-module/navigation/view.js",
+ "./playlist/view": "./build-module/playlist/view.js",
"./query/view": "./build-module/query/view.js",
"./search/view": "./build-module/search/view.js"
},
diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss
index 3099dc3cd928ec..dabdad6a8cfd62 100644
--- a/packages/block-library/src/editor.scss
+++ b/packages/block-library/src/editor.scss
@@ -31,6 +31,7 @@
@import "./nextpage/editor.scss";
@import "./page-list/editor.scss";
@import "./paragraph/editor.scss";
+@import "./playlist/editor.scss";
@import "./post-excerpt/editor.scss";
@import "./pullquote/editor.scss";
@import "./rss/editor.scss";
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 262f11de6ee22d..9856287fbdaee9 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -74,6 +74,8 @@ import * as pattern from './pattern';
import * as pageList from './page-list';
import * as pageListItem from './page-list-item';
import * as paragraph from './paragraph';
+import * as playlist from './playlist';
+import * as playlistTrack from './playlist-track';
import * as postAuthor from './post-author';
import * as postAuthorName from './post-author-name';
import * as postAuthorBiography from './post-author-biography';
@@ -241,6 +243,11 @@ const getAllBlocks = () => {
blocks.push( formSubmissionNotification );
}
+ if ( window?.__experimentalEnableBlockExperiments ) {
+ blocks.push( playlist );
+ blocks.push( playlistTrack );
+ }
+
// When in a WordPress context, conditionally
// add the classic block and TinyMCE editor
// under any of the following conditions:
diff --git a/packages/block-library/src/playlist-track/block.json b/packages/block-library/src/playlist-track/block.json
new file mode 100755
index 00000000000000..0bd2795836a80c
--- /dev/null
+++ b/packages/block-library/src/playlist-track/block.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "__experimental": true,
+ "name": "core/playlist-track",
+ "title": "Playlist track",
+ "category": "media",
+ "parent": [ "core/playlist" ],
+ "description": "Playlist track.",
+ "keywords": [ "music", "sound" ],
+ "textdomain": "default",
+ "usesContext": [ "showArtists", "currentTrack" ],
+ "attributes": {
+ "blob": {
+ "type": "string",
+ "role": "local"
+ },
+ "id": {
+ "type": "number"
+ },
+ "uniqueId": {
+ "type": "string"
+ },
+ "src": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "default": "audio"
+ },
+ "album": {
+ "type": "string"
+ },
+ "artist": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "length": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "supports": {
+ "html": false,
+ "interactivity": {
+ "clientNavigation": true
+ },
+ "reusable": false
+ },
+ "editorStyle": "wp-block-playlist-track-editor",
+ "style": "wp-block-playlist-track"
+}
diff --git a/packages/block-library/src/playlist-track/edit.js b/packages/block-library/src/playlist-track/edit.js
new file mode 100755
index 00000000000000..347537d73bf842
--- /dev/null
+++ b/packages/block-library/src/playlist-track/edit.js
@@ -0,0 +1,295 @@
+/**
+ * External dependencies
+ */
+import { v4 as uuid } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import { isBlobURL } from '@wordpress/blob';
+import { useRef, useState } from '@wordpress/element';
+import {
+ MediaPlaceholder,
+ MediaReplaceFlow,
+ MediaUpload,
+ MediaUploadCheck,
+ BlockIcon,
+ useBlockProps,
+ BlockControls,
+ InspectorControls,
+ RichText,
+} from '@wordpress/block-editor';
+import {
+ Button,
+ PanelBody,
+ TextControl,
+ BaseControl,
+ Spinner,
+} from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { __ } from '@wordpress/i18n';
+import { audio as icon } from '@wordpress/icons';
+import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
+
+/**
+ * Internal dependencies
+ */
+import { useUploadMediaFromBlobURL } from '../utils/hooks';
+
+const ALLOWED_MEDIA_TYPES = [ 'audio' ];
+const ALBUM_COVER_ALLOWED_MEDIA_TYPES = [ 'image' ];
+
+const PlaylistTrackEdit = ( { attributes, setAttributes, context } ) => {
+ // Note that 'id' is the media attachment ID, while 'uniqueId' is a unique identifier.
+ // This is to make sure that the same media can be used in more than one track.
+ const { id, uniqueId, src, album, artist, image, length, title } =
+ attributes;
+ const [ temporaryURL, setTemporaryURL ] = useState( attributes.blob );
+ const showArtists = context?.showArtists;
+ const currentTrack = context?.currentTrack;
+ const imageButton = useRef();
+ const blockProps = useBlockProps();
+ const { createErrorNotice } = useDispatch( noticesStore );
+ function onUploadError( message ) {
+ createErrorNotice( message, { type: 'snackbar' } );
+ }
+
+ useUploadMediaFromBlobURL( {
+ src: temporaryURL,
+ allowedTypes: ALLOWED_MEDIA_TYPES,
+ onChange: onSelectTrack,
+ onError: onUploadError,
+ } );
+
+ function onSelectTrack( media ) {
+ if ( ! media || ! media.url ) {
+ // In this case there was an error and we should continue in the editing state
+ // previous attributes should be removed because they may be temporary blob urls.
+ setAttributes( {
+ blob: undefined,
+ id: undefined,
+ uniqueId: undefined,
+ artist: undefined,
+ album: undefined,
+ image: undefined,
+ length: undefined,
+ title: undefined,
+ url: undefined,
+ } );
+ setTemporaryURL();
+ return;
+ }
+
+ if ( isBlobURL( media.url ) ) {
+ setTemporaryURL( media.url );
+ return;
+ }
+
+ setAttributes( {
+ blob: undefined,
+ id: media.id,
+ uniqueId: uuid(),
+ src: media.url,
+ artist:
+ media.artist ||
+ media?.meta?.artist ||
+ media?.media_details?.artist ||
+ __( 'Unknown artist' ),
+ album:
+ media.album ||
+ media?.meta?.album ||
+ media?.media_details?.album ||
+ __( 'Unknown album' ),
+ // Prevent using the default media attachment icon as the track image.
+ image:
+ media?.image?.src &&
+ media?.image?.src.endsWith( '/images/media/audio.svg' )
+ ? ''
+ : media?.image?.src,
+ length: media?.fileLength || media?.media_details?.length_formatted,
+ title: media.title,
+ } );
+ setTemporaryURL();
+ }
+
+ function onSelectAlbumCoverImage( coverImage ) {
+ setAttributes( { image: coverImage.url } );
+ }
+
+ function onRemoveAlbumCoverImage() {
+ setAttributes( { image: undefined } );
+
+ // Move focus back to the Media Upload button.
+ imageButton.current.focus();
+ }
+
+ if ( ! src && ! temporaryURL ) {
+ return (
+
+ }
+ labels={ {
+ title: __( 'Track' ),
+ instructions: __(
+ 'Upload an audio file or pick one from your media library.'
+ ),
+ } }
+ onSelect={ onSelectTrack }
+ accept="audio/*"
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ value={ attributes }
+ onError={ onUploadError }
+ />
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {
+ setAttributes( { artist: artistValue } );
+ } }
+ />
+ {
+ setAttributes( { album: albumValue } );
+ } }
+ />
+ {
+ setAttributes( { title: titleValue } );
+ } }
+ />
+
+
+
+ { __( 'Album cover image' ) }
+
+ { !! image && (
+

+ ) }
+
(
+
+ ) }
+ />
+ { !! image && (
+
+ ) }
+
+
+
+
+
+ { !! temporaryURL && }
+
+
+ >
+ );
+};
+
+export default PlaylistTrackEdit;
diff --git a/packages/block-library/src/playlist-track/index.js b/packages/block-library/src/playlist-track/index.js
new file mode 100755
index 00000000000000..cfaa381675ed09
--- /dev/null
+++ b/packages/block-library/src/playlist-track/index.js
@@ -0,0 +1,21 @@
+/**
+ * WordPress dependencies
+ */
+import { audio as icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import initBlock from '../utils/init-block';
+import metadata from './block.json';
+import edit from './edit';
+
+const { name } = metadata;
+export { metadata, name };
+
+export const settings = {
+ icon,
+ edit,
+};
+
+export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/playlist-track/index.php b/packages/block-library/src/playlist-track/index.php
new file mode 100755
index 00000000000000..427d9ae733c098
--- /dev/null
+++ b/packages/block-library/src/playlist-track/index.php
@@ -0,0 +1,74 @@
+ $unique_id,
+ )
+ );
+
+ $html = '';
+ $html .= '';
+ $html .= '';
+
+ return $html;
+}
+
+/**
+ * Registers the `core/playlist-track` block on server.
+ *
+ * @since 6.9.0
+ */
+function register_block_core_playlist_track() {
+ register_block_type_from_metadata(
+ __DIR__ . '/playlist-track',
+ array(
+ 'render_callback' => 'render_block_core_playlist_track',
+ )
+ );
+}
+add_action( 'init', 'register_block_core_playlist_track' );
diff --git a/packages/block-library/src/playlist-track/init.js b/packages/block-library/src/playlist-track/init.js
new file mode 100755
index 00000000000000..79f0492c2cb2f8
--- /dev/null
+++ b/packages/block-library/src/playlist-track/init.js
@@ -0,0 +1,6 @@
+/**
+ * Internal dependencies
+ */
+import { init } from './';
+
+export default init();
diff --git a/packages/block-library/src/playlist-track/style.scss b/packages/block-library/src/playlist-track/style.scss
new file mode 100755
index 00000000000000..67f91535ace288
--- /dev/null
+++ b/packages/block-library/src/playlist-track/style.scss
@@ -0,0 +1,31 @@
+.wp-block-playlist-track {
+ border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ padding: $grid-unit-10;
+
+ .wp-block-playlist-track__button {
+ display: flex;
+ flex-wrap: wrap;
+ height: auto;
+ min-width: 100%;
+ padding: 0;
+ font-size: 14px;
+ font-family: inherit;
+ text-align: left; // Override default button text-align.
+ line-height: 1.5;
+ background-color: transparent;
+ color: inherit;
+ border: 0;
+ border-radius: 1px;
+ outline-offset: 2px;
+
+ span {
+ margin-right: $grid-unit-10;
+ }
+ .wp-block-playlist-track__length {
+ margin-left: auto;
+ }
+ &[aria-current="true"] {
+ font-weight: 600;
+ }
+ }
+}
diff --git a/packages/block-library/src/playlist/block.json b/packages/block-library/src/playlist/block.json
new file mode 100644
index 00000000000000..1e7eda54081dc3
--- /dev/null
+++ b/packages/block-library/src/playlist/block.json
@@ -0,0 +1,79 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "__experimental": true,
+ "name": "core/playlist",
+ "title": "Playlist",
+ "category": "media",
+ "description": "Embed a simple playlist.",
+ "keywords": [ "music", "sound" ],
+ "textdomain": "default",
+ "allowedBlocks": [ "core/playlist-track" ],
+ "attributes": {
+ "currentTrack": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "default": "audio"
+ },
+ "order": {
+ "type": "string",
+ "default": "ASC"
+ },
+ "showTracklist": {
+ "type": "boolean",
+ "default": true
+ },
+ "showImages": {
+ "type": "boolean",
+ "default": true
+ },
+ "showArtists": {
+ "type": "boolean",
+ "default": true
+ },
+ "showNumbers": {
+ "type": "boolean",
+ "default": true
+ },
+ "caption": {
+ "type": "string"
+ }
+ },
+ "providesContext": {
+ "showArtists": "showArtists",
+ "currentTrack": "currentTrack"
+ },
+ "supports": {
+ "anchor": true,
+ "align": true,
+ "color": {
+ "gradients": true,
+ "link": true,
+ "__experimentalDefaultControls": {
+ "background": true,
+ "text": true
+ }
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true,
+ "__experimentalDefaultControls": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true
+ }
+ },
+ "interactivity": true,
+ "spacing": {
+ "margin": true,
+ "padding": true
+ }
+ },
+ "editorStyle": "wp-block-playlist-editor",
+ "style": "wp-block-playlist"
+}
diff --git a/packages/block-library/src/playlist/edit.js b/packages/block-library/src/playlist/edit.js
new file mode 100644
index 00000000000000..6507ab0353133c
--- /dev/null
+++ b/packages/block-library/src/playlist/edit.js
@@ -0,0 +1,469 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import { v4 as uuid } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import { useState, useCallback, useEffect } from '@wordpress/element';
+import {
+ store as blockEditorStore,
+ MediaPlaceholder,
+ MediaReplaceFlow,
+ BlockIcon,
+ useBlockProps,
+ useInnerBlocksProps,
+ BlockControls,
+ InspectorControls,
+ InnerBlocks,
+} from '@wordpress/block-editor';
+import {
+ PanelBody,
+ ToggleControl,
+ Disabled,
+ SelectControl,
+ Spinner,
+} from '@wordpress/components';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { __, _x, sprintf } from '@wordpress/i18n';
+import { audio as icon } from '@wordpress/icons';
+import { safeHTML, __unstableStripHTML as stripHTML } from '@wordpress/dom';
+import { createBlock } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { Caption } from '../utils/caption';
+
+const ALLOWED_MEDIA_TYPES = [ 'audio' ];
+
+const CurrentTrack = ( { track, showImages, onTrackEnd } ) => {
+ /**
+ * dangerouslySetInnerHTML and safeHTML are used because
+ * the media library allows using some HTML tags in the title, artist, and album fields.
+ */
+ const trackTitle = {
+ dangerouslySetInnerHTML: {
+ __html: safeHTML( track?.title ? track.title : __( 'Untitled' ) ),
+ },
+ };
+ const trackArtist = {
+ dangerouslySetInnerHTML: {
+ __html: safeHTML(
+ track?.artist ? track.artist : __( 'Unknown artist' )
+ ),
+ },
+ };
+ const trackAlbum = {
+ dangerouslySetInnerHTML: {
+ __html: safeHTML(
+ track?.album ? track.album : __( 'Unknown album' )
+ ),
+ },
+ };
+
+ let ariaLabel;
+ if ( track?.title && track?.artist && track?.album ) {
+ ariaLabel = stripHTML(
+ sprintf(
+ /* translators: %1$s: track title, %2$s artist name, %3$s: album name. */
+ _x(
+ '%1$s by %2$s from the album %3$s',
+ 'track title, artist name, album name'
+ ),
+ track?.title,
+ track?.artist,
+ track?.album
+ )
+ );
+ } else if ( track?.title ) {
+ ariaLabel = stripHTML( track.title );
+ } else {
+ ariaLabel = stripHTML( __( 'Untitled' ) );
+ }
+
+ return (
+ <>
+
+ { showImages && track?.image && (
+

+ ) }
+
+ { ! track?.title ? (
+
+
+
+ ) : (
+
+ ) }
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const PlaylistEdit = ( {
+ attributes,
+ setAttributes,
+ isSelected,
+ insertBlocksAfter,
+ clientId,
+} ) => {
+ const {
+ order,
+ showTracklist,
+ showNumbers,
+ showImages,
+ showArtists,
+ currentTrack,
+ tagName: TagName = showNumbers ? 'ol' : 'ul',
+ } = attributes;
+ const [ trackListIndex, setTrackListIndex ] = useState( 0 );
+ const blockProps = useBlockProps();
+ const { replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent } =
+ useDispatch( blockEditorStore );
+ const { createErrorNotice } = useDispatch( noticesStore );
+ function onUploadError( message ) {
+ createErrorNotice( message, { type: 'snackbar' } );
+ }
+ const { updateBlockAttributes } = useDispatch( blockEditorStore );
+
+ const { innerBlockTracks } = useSelect(
+ ( select ) => {
+ const { getBlock: _getBlock } = select( blockEditorStore );
+ return {
+ innerBlockTracks: _getBlock( clientId )?.innerBlocks ?? [],
+ };
+ },
+ [ clientId ]
+ );
+
+ // Ensure that each inner block has a unique ID,
+ // even if a track is duplicated.
+ useEffect( () => {
+ const seen = new Set();
+ let hasDuplicates = false;
+ const updatedBlocks = innerBlockTracks.map( ( block ) => {
+ if ( seen.has( block.attributes.uniqueId ) ) {
+ hasDuplicates = true;
+ return {
+ ...block,
+ attributes: {
+ ...block.attributes,
+ uniqueId: uuid(),
+ },
+ };
+ }
+ seen.add( block.attributes.uniqueId );
+ return block;
+ } );
+ if ( hasDuplicates ) {
+ replaceInnerBlocks( clientId, updatedBlocks );
+ }
+ }, [ innerBlockTracks, clientId, replaceInnerBlocks ] );
+
+ // Create a list of tracks from the inner blocks,
+ // but skip blocks that do not have a uniqueId attribute, such as the media placeholder.
+ const validTracks = innerBlockTracks.filter(
+ ( block ) => !! block.attributes.uniqueId
+ );
+ const tracks = validTracks.map( ( block ) => block.attributes );
+ const firstTrackId = validTracks[ 0 ]?.attributes?.uniqueId;
+
+ // updateBlockAttributes is used to force updating the parent playlist block
+ // when the currentTrack changes. Using setAttributes directly does not update
+ // the currentTrack when multiple tracks are moved at the same time.
+ useEffect( () => {
+ if ( tracks.length === 0 ) {
+ // If there are no tracks but currentTrack is set, set it to null.
+ if ( currentTrack !== null ) {
+ updateBlockAttributes( clientId, { currentTrack: null } );
+ }
+ } else if (
+ // If the currentTrack is not the first track, update it to the first track.
+ firstTrackId &&
+ firstTrackId !== currentTrack
+ ) {
+ updateBlockAttributes( clientId, { currentTrack: firstTrackId } );
+ }
+ }, [
+ tracks,
+ currentTrack,
+ firstTrackId,
+ clientId,
+ updateBlockAttributes,
+ ] );
+
+ const onSelectTracks = useCallback(
+ ( media ) => {
+ if ( ! media ) {
+ return;
+ }
+
+ if ( ! Array.isArray( media ) ) {
+ media = [ media ];
+ }
+
+ const trackAttributes = ( track ) => ( {
+ id: track.id || track.url, // Attachment ID or URL.
+ uniqueId: uuid(), // Unique ID for the track.
+ src: track.url,
+ title: track.title,
+ artist:
+ track.artist ||
+ track?.meta?.artist ||
+ track?.media_details?.artist ||
+ __( 'Unknown artist' ),
+ album:
+ track.album ||
+ track?.meta?.album ||
+ track?.media_details?.album ||
+ __( 'Unknown album' ),
+ length:
+ track?.fileLength || track?.media_details?.length_formatted,
+ // Prevent using the default media attachment icon as the track image.
+ // Note: Image is not available when a new track is uploaded.
+ image:
+ track?.image?.src &&
+ track?.image?.src.endsWith( '/images/media/audio.svg' )
+ ? ''
+ : track?.image?.src,
+ } );
+
+ const trackList = media.map( trackAttributes );
+ __unstableMarkNextChangeAsNotPersistent();
+ setAttributes( {
+ currentTrack:
+ trackList.length > 0 ? trackList[ 0 ].uniqueId : null,
+ } );
+
+ const newBlocks = trackList.map( ( track ) =>
+ createBlock( 'core/playlist-track', track )
+ );
+ // Replace the inner blocks with the new tracks.
+ replaceInnerBlocks( clientId, newBlocks );
+ },
+ [
+ __unstableMarkNextChangeAsNotPersistent,
+ setAttributes,
+ replaceInnerBlocks,
+ clientId,
+ ]
+ );
+
+ const onTrackEnd = useCallback( () => {
+ /* If there are tracks left, play the next track */
+ if ( trackListIndex < tracks.length - 1 ) {
+ if ( tracks[ trackListIndex + 1 ]?.uniqueId ) {
+ setTrackListIndex( trackListIndex + 1 );
+ setAttributes( {
+ currentTrack: tracks[ trackListIndex + 1 ].uniqueId,
+ } );
+ }
+ } else {
+ setTrackListIndex( 0 );
+ if ( tracks[ 0 ].uniqueId ) {
+ setAttributes( { currentTrack: tracks[ 0 ].uniqueId } );
+ } else if ( tracks.length > 0 ) {
+ const validTrack = tracks.find(
+ ( track ) => track.uniqueId !== undefined
+ );
+ if ( validTrack ) {
+ setAttributes( { currentTrack: validTrack.uniqueId } );
+ }
+ }
+ }
+ }, [ setAttributes, trackListIndex, tracks ] );
+
+ const onChangeOrder = useCallback(
+ ( trackOrder ) => {
+ const sortedBlocks = [ ...innerBlockTracks ].sort( ( a, b ) => {
+ if ( trackOrder === 'ASC' ) {
+ return a.attributes.uniqueId.localeCompare(
+ b.attributes.uniqueId
+ );
+ }
+ return b.attributes.uniqueId.localeCompare(
+ a.attributes.uniqueId
+ );
+ } );
+ const sortedTracks = sortedBlocks.map(
+ ( block ) => block.attributes
+ );
+ replaceInnerBlocks( clientId, sortedBlocks );
+ setAttributes( {
+ order: trackOrder,
+ currentTrack:
+ sortedTracks.length > 0 &&
+ sortedTracks[ 0 ].uniqueId !== currentTrack
+ ? sortedTracks[ 0 ].uniqueId
+ : currentTrack,
+ } );
+ },
+ [
+ clientId,
+ currentTrack,
+ innerBlockTracks,
+ replaceInnerBlocks,
+ setAttributes,
+ ]
+ );
+
+ function toggleAttribute( attribute ) {
+ return ( newValue ) => {
+ setAttributes( { [ attribute ]: newValue } );
+ };
+ }
+
+ const hasSelectedChild = useSelect(
+ ( select ) =>
+ select( blockEditorStore ).hasSelectedInnerBlock( clientId ),
+ [ clientId ]
+ );
+
+ const hasAnySelected = isSelected || hasSelectedChild;
+
+ const innerBlocksProps = useInnerBlocksProps( blockProps, {
+ __experimentalAppenderTagName: 'li',
+ renderAppender: hasAnySelected && InnerBlocks.ButtonBlockAppender,
+ } );
+
+ if ( ! tracks || ( Array.isArray( tracks ) && tracks.length === 0 ) ) {
+ return (
+
+ }
+ labels={ {
+ title: __( 'Playlist' ),
+ instructions: __(
+ 'Upload an audio file or pick one from your media library.'
+ ),
+ } }
+ onSelect={ onSelectTracks }
+ accept="audio/*"
+ multiple
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ onError={ onUploadError }
+ />
+
+ );
+ }
+
+ return (
+ <>
+
+ track.id )
+ .map( ( track ) => track.id ) }
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ onError={ onUploadError }
+ />
+
+
+
+
+ { showTracklist && (
+ <>
+
+
+ >
+ ) }
+
+ onChangeOrder( value ) }
+ />
+
+
+
+
+
+
+ { showTracklist && (
+
+ { innerBlocksProps.children }
+
+ ) }
+
+
+ >
+ );
+};
+
+export default PlaylistEdit;
diff --git a/packages/block-library/src/playlist/editor.scss b/packages/block-library/src/playlist/editor.scss
new file mode 100644
index 00000000000000..e202eeca4cbe44
--- /dev/null
+++ b/packages/block-library/src/playlist/editor.scss
@@ -0,0 +1,6 @@
+.wp-block-playlist {
+ &.is-placeholder {
+ padding: 0;
+ border: none;
+ }
+}
diff --git a/packages/block-library/src/playlist/index.js b/packages/block-library/src/playlist/index.js
new file mode 100644
index 00000000000000..6a6d898a036ccf
--- /dev/null
+++ b/packages/block-library/src/playlist/index.js
@@ -0,0 +1,23 @@
+/**
+ * WordPress dependencies
+ */
+import { audio as icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import initBlock from '../utils/init-block';
+import metadata from './block.json';
+import edit from './edit';
+import save from './save';
+
+const { name } = metadata;
+export { metadata, name };
+
+export const settings = {
+ icon,
+ edit,
+ save,
+};
+
+export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/playlist/index.php b/packages/block-library/src/playlist/index.php
new file mode 100644
index 00000000000000..5b5e4e8ed80fe5
--- /dev/null
+++ b/packages/block-library/src/playlist/index.php
@@ -0,0 +1,173 @@
+inner_blocks ) ) {
+ foreach ( $block->inner_blocks as $inner_block ) {
+ if ( 'core/playlist-track' === $inner_block->name ) {
+ $inner_block->context['playlistId'] = $playlist_id;
+
+ $track_attributes = $inner_block->attributes;
+ $unique_id = isset( $track_attributes['uniqueId'] ) ? $track_attributes['uniqueId'] : wp_generate_uuid4();
+ $playlist_tracks[] = $unique_id;
+
+ $inner_block->attributes['uniqueId'] = $unique_id;
+
+ // Extract track metadata from block attributes.
+ $title = isset( $track_attributes['title'] ) && ! empty( $track_attributes['title'] ) ? $track_attributes['title'] : __( 'Unknown title' );
+ $artist = isset( $track_attributes['artist'] ) ? $track_attributes['artist'] : '';
+ $album = isset( $track_attributes['album'] ) ? $track_attributes['album'] : '';
+ $image = isset( $track_attributes['image'] ) ? $track_attributes['image'] : '';
+ $url = isset( $track_attributes['src'] ) ? $track_attributes['src'] : '';
+ $aria_label = $title;
+
+ if ( $title && $artist && $album ) {
+ $aria_label = sprintf(
+ /* translators: %1$s: track title, %2$s artist name, %3$s: album name. */
+ _x( '%1$s by %2$s from the album %3$s', 'track title, artist name, album name' ),
+ $title,
+ $artist,
+ $album
+ );
+ }
+
+ $tracks_data[ $unique_id ] = array(
+ 'url' => $url,
+ 'title' => $title,
+ 'artist' => $artist,
+ 'album' => $album,
+ 'image' => $image,
+ 'ariaLabel' => $aria_label,
+ );
+
+ if ( $unique_id === $current_media_id ) {
+ $current_unique_id = $unique_id;
+ }
+ }
+ }
+ }
+
+ // If there are no tracks but there is a currentTrack set, do not render the block.
+ // This can happen for example if the currentTrack was not deleted correctly
+ // or if the block is manually edited in the code editor mode.
+ if ( empty( $playlist_tracks ) || ! in_array( $current_media_id, $playlist_tracks, true ) ) {
+ return '';
+ }
+
+ wp_enqueue_script_module( '@wordpress/block-library/playlist/view' );
+
+ // Add the playlist tracks to the global state,
+ // but keep them isolated from other playlists with the help of playlistId.
+ wp_interactivity_state(
+ 'core/playlist',
+ array(
+ 'playlists' => array(
+ $playlist_id => array(
+ 'tracks' => $tracks_data,
+ ),
+ ),
+ )
+ );
+
+ // Create the HTML for the current track which shows above the tracklist.
+ $html = '';
+
+ // The alt attribute is intentionally left empty, as the image is decorative.
+ if ( isset( $attributes['showImages'] ) ? $attributes['showImages'] : false ) {
+ $html .=
+ '
![]()
';
+ }
+
+ $html .= '
+
+
+
+ ';
+
+ // Add the HTML for the current track inside the figure.
+ $figure = null;
+ preg_match( '/]*>/', $content, $figure );
+ if ( ! empty( $figure[0] ) ) {
+ $content = preg_replace( '/(]*>)/', '$1' . $html, $content, 1 );
+ }
+
+ $processor = new WP_HTML_Tag_Processor( $content );
+ $processor->next_tag( 'figure' );
+ $processor->set_attribute( 'data-wp-interactive', 'core/playlist' );
+ $processor->set_attribute(
+ 'data-wp-context',
+ json_encode(
+ array(
+ 'playlistId' => $playlist_id,
+ 'currentId' => $current_unique_id,
+ 'tracks' => $playlist_tracks,
+ 'isPlaying' => false,
+ )
+ )
+ );
+
+ return $processor->get_updated_html();
+}
+
+/**
+ * Registers the `core/playlist` block on server.
+ *
+ * @since 6.9.0
+ */
+function register_block_core_playlist() {
+ register_block_type_from_metadata(
+ __DIR__ . '/playlist',
+ array(
+ 'render_callback' => 'render_block_core_playlist',
+ )
+ );
+}
+add_action( 'init', 'register_block_core_playlist' );
diff --git a/packages/block-library/src/playlist/init.js b/packages/block-library/src/playlist/init.js
new file mode 100644
index 00000000000000..79f0492c2cb2f8
--- /dev/null
+++ b/packages/block-library/src/playlist/init.js
@@ -0,0 +1,6 @@
+/**
+ * Internal dependencies
+ */
+import { init } from './';
+
+export default init();
diff --git a/packages/block-library/src/playlist/save.js b/packages/block-library/src/playlist/save.js
new file mode 100644
index 00000000000000..07d2e63b7e4e3e
--- /dev/null
+++ b/packages/block-library/src/playlist/save.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ RichText,
+ useBlockProps,
+ useInnerBlocksProps,
+ __experimentalGetElementClassName,
+} from '@wordpress/block-editor';
+
+export default function saveWithInnerBlocks( { attributes } ) {
+ const {
+ caption,
+ showNumbers,
+ showTracklist,
+ showArtists,
+ tagName: TagName = showNumbers ? 'ol' : 'ul',
+ } = attributes;
+
+ const blockProps = useBlockProps.save();
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+ return (
+
+
+ { innerBlocksProps.children }
+
+ { ! RichText.isEmpty( caption ) && (
+
+ ) }
+
+ );
+}
diff --git a/packages/block-library/src/playlist/style.scss b/packages/block-library/src/playlist/style.scss
new file mode 100644
index 00000000000000..0cb1572d3aff7b
--- /dev/null
+++ b/packages/block-library/src/playlist/style.scss
@@ -0,0 +1,75 @@
+.wp-block-playlist {
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ border-radius: 2px;
+ padding: $grid-unit-20;
+
+ .wp-block-playlist__current-item {
+ display: flex;
+ align-items: center;
+ gap: var(--Spacing-6, 24px);
+ align-self: stretch;
+ padding-bottom: $grid-unit-20;
+ border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ margin-bottom: $grid-unit-20;
+
+ img {
+ border-radius: 2px;
+ }
+
+ div {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ & .wp-block-playlist__current-item-artist-album {
+ flex-direction: row;
+ font-weight: 400;
+ font-size: 16px;
+ }
+
+ .wp-block-playlist__item-title {
+ font-weight: 600;
+ word-break: break-all;
+ }
+
+ .wp-block-playlist__item-album {
+ font-style: italic;
+ }
+ }
+
+ audio {
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .wp-block-playlist__tracklist {
+ font-size: 14px;
+
+ &.wp-block-playlist__tracklist-is-hidden {
+ display: none;
+ }
+
+ &.wp-block-playlist__tracklist-artist-is-hidden {
+ // Hide the artist name, which is in the playlist-track block.
+ .wp-block-playlist-track__artist {
+ display: none;
+ }
+ }
+ }
+
+ ul.wp-block-playlist__tracklist {
+ padding-left: 0;
+ list-style: none;
+ }
+
+ ol.wp-block-playlist__tracklist {
+ padding-left: $grid-unit-20;
+ }
+
+ li.block-list-appender.block-list-appender {
+ position: initial;
+ margin-top: $grid-unit-20;
+ }
+}
diff --git a/packages/block-library/src/playlist/view.js b/packages/block-library/src/playlist/view.js
new file mode 100644
index 00000000000000..9ef0383c774b41
--- /dev/null
+++ b/packages/block-library/src/playlist/view.js
@@ -0,0 +1,69 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getContext, getElement } from '@wordpress/interactivity';
+
+store(
+ 'core/playlist',
+ {
+ state: {
+ playlists: {},
+ get currentTrack() {
+ const { currentId, playlistId } = getContext();
+ if ( ! currentId || ! playlistId ) {
+ return {};
+ }
+ const playlist = this.playlists[ playlistId ];
+ if ( ! playlist ) {
+ return {};
+ }
+ return playlist.tracks[ currentId ] || {};
+ },
+ get isCurrentTrack() {
+ const { currentId, uniqueId } = getContext();
+ return currentId === uniqueId;
+ },
+ },
+ actions: {
+ changeTrack() {
+ const context = getContext();
+ context.currentId = context.uniqueId;
+ context.isPlaying = true;
+ },
+ isPlaying() {
+ const context = getContext();
+ context.isPlaying = true;
+ },
+ isPaused() {
+ const context = getContext();
+ context.isPlaying = false;
+ },
+ nextSong() {
+ const context = getContext();
+ const currentIndex = context.tracks.findIndex(
+ ( uniqueId ) => uniqueId === context.currentId
+ );
+ const nextTrack = context.tracks[ currentIndex + 1 ];
+ if ( nextTrack ) {
+ context.currentId = nextTrack;
+ const { ref } = getElement();
+ // Waits a moment before changing the track, since
+ // immediately changing the track can be jarring.
+ setTimeout( () => {
+ ref.play();
+ }, 1000 );
+ }
+ },
+ },
+ callbacks: {
+ autoPlay() {
+ const context = getContext();
+ const { ref } = getElement();
+ if ( context.currentId && context.isPlaying ) {
+ ref.play();
+ }
+ },
+ },
+ },
+ { lock: true }
+);
diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss
index 3c8b8e623cc4d9..259e0416faaa40 100644
--- a/packages/block-library/src/style.scss
+++ b/packages/block-library/src/style.scss
@@ -33,6 +33,8 @@
@import "./navigation-link/style.scss";
@import "./page-list/style.scss";
@import "./paragraph/style.scss";
+@import "./playlist/style.scss";
+@import "./playlist-track/style.scss";
@import "./post-author/style.scss";
@import "./post-author-biography/style.scss";
@import "./post-comments-form/style.scss";