diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index 34db859f71d1d..056add23addf0 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, tracks, 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:** blob, id, src
+
## 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 c3fdb26700c58..c17a3617a278e 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 e507ef3636726..923712d84d194 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -30,6 +30,7 @@
"./file/view": "./build-module/file/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 a16d5a6c2c69c..375659da34e4b 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-author/editor.scss";
@import "./post-excerpt/editor.scss";
@import "./pullquote/editor.scss";
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 56365c87a268f..f5fce4b140dcb 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';
@@ -239,6 +241,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 0000000000000..51b8aa3714057
--- /dev/null
+++ b/packages/block-library/src/playlist-track/block.json
@@ -0,0 +1,34 @@
+{
+ "$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"
+ },
+ "src": {
+ "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 0000000000000..51ec06b87277f
--- /dev/null
+++ b/packages/block-library/src/playlist-track/edit.js
@@ -0,0 +1,297 @@
+/**
+ * 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, useSelect } from '@wordpress/data';
+import { store as coreStore, useEntityProp } from '@wordpress/core-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 } ) => {
+ const { id, src } = 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' } );
+ }
+
+ const track = useSelect(
+ ( select ) => {
+ const media = id && select( coreStore ).getMedia( id );
+ const _isRequestingMedia =
+ !! id &&
+ ! select( coreStore ).hasFinishedResolution( 'getMedia', [
+ id,
+ { context: 'view' },
+ ] );
+
+ return {
+ 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,
+ fileLength:
+ media?.fileLength || media?.media_details?.length_formatted,
+ // Important: This is not the media details title, but the title of the attachment.
+ title: media?.title.rendered,
+ url: media?.url,
+ isRequestingMedia: _isRequestingMedia,
+ };
+ },
+ [ id ]
+ );
+
+ 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,
+ } );
+ setTemporaryURL();
+ return;
+ }
+
+ if ( isBlobURL( media.url ) ) {
+ setTemporaryURL( media.url );
+ return;
+ }
+
+ setAttributes( {
+ blob: undefined,
+ id: media.id,
+ src: media.url,
+ } );
+ setTemporaryURL();
+ }
+
+ function onSelectAlbumCoverImage( coverImage ) {
+ setAttributes( { image: coverImage.url } );
+ }
+
+ function onRemoveAlbumCoverImage() {
+ setAttributes( { image: undefined } );
+
+ // Move focus back to the Media Upload button.
+ imageButton.current.focus();
+ }
+
+ const [ title, setTitle ] = useEntityProp( 'root', 'media', 'title', id );
+
+ 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 } );
+ } }
+ />
+
+
+
+
+ { __( 'Album cover image' ) }
+
+ { !! track.image && (
+
+ ) }
+
(
+
+ ) }
+ />
+ { !! track.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 0000000000000..cfaa381675ed0
--- /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 0000000000000..8217a57892bcc
--- /dev/null
+++ b/packages/block-library/src/playlist-track/index.php
@@ -0,0 +1,104 @@
+ $unique_id ) );
+
+ wp_interactivity_state(
+ 'core/playlist',
+ array(
+ 'tracks' => array(
+ $unique_id => array(
+ 'media_id' => $media_id,
+ 'url' => $url,
+ 'title' => $title,
+ 'artist' => $artist,
+ 'album' => $album,
+ 'image' => $image,
+ 'length' => $length,
+ 'ariaLabel' => $aria_label,
+ ),
+ ),
+ )
+ );
+
+ $html = '';
+ $html .= '';
+ $html .= '';
+
+ return $html;
+}
+
+/**
+ * Registers the `core/playlist-track` block on server.
+ *
+ * @since 6.8.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 0000000000000..79f0492c2cb2f
--- /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 0000000000000..5745c30a1fa72
--- /dev/null
+++ b/packages/block-library/src/playlist-track/style.scss
@@ -0,0 +1,29 @@
+.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;
+
+ 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 0000000000000..c5e2b5997a569
--- /dev/null
+++ b/packages/block-library/src/playlist/block.json
@@ -0,0 +1,82 @@
+{
+ "$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": {
+ "tracks": {
+ "type": "array"
+ },
+ "currentTrack": {
+ "type": "number"
+ },
+ "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 0000000000000..2203fb3192083
--- /dev/null
+++ b/packages/block-library/src/playlist/edit.js
@@ -0,0 +1,472 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * 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 coreStore } from '@wordpress/core-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 = ( { trackData, showImages, onTrackEnd } ) => {
+ const id = trackData?.id;
+ /**
+ * dangerouslySetInnerHTML and safeHTML are used because
+ * the media library allows using some HTML tags in the title, artist, and album fields.
+ */
+ const track = useSelect(
+ ( select ) => {
+ const media = id && select( coreStore ).getMedia( id );
+ return {
+ 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,
+ fileLength:
+ media?.fileLength || media?.media_details?.length_formatted,
+ // Important: This is not the media details title, but the title of the attachment.
+ title: media?.title.rendered,
+ url: media?.url,
+ };
+ },
+ [ id ]
+ );
+
+ 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 {
+ tracks,
+ 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 { innerBlockTracks } = useSelect(
+ ( select ) => {
+ const { getBlock: _getBlock } = select( blockEditorStore );
+ return {
+ innerBlockTracks: _getBlock( clientId )?.innerBlocks ?? [],
+ };
+ },
+ [ clientId ]
+ );
+
+ // Monitor changes to the inner blocks.
+ useEffect( () => {
+ // If the tracks have been repositioned, update the `tracks` block attribute.
+ if (
+ innerBlockTracks.some(
+ ( innerTrack, index ) =>
+ innerTrack.attributes.id !== tracks[ index ]?.id
+ )
+ ) {
+ const sortedTracks = innerBlockTracks.map(
+ ( track ) => track.attributes
+ );
+ __unstableMarkNextChangeAsNotPersistent();
+ setAttributes( {
+ tracks: sortedTracks,
+ } );
+ // Update the current track if the first track has changed, and there is a track id.
+ if (
+ sortedTracks.length > 0 &&
+ sortedTracks[ 0 ].id &&
+ sortedTracks[ 0 ].id !== currentTrack
+ ) {
+ __unstableMarkNextChangeAsNotPersistent();
+ setAttributes( { currentTrack: sortedTracks[ 0 ].id } );
+ }
+ }
+
+ const updatedTracks = innerBlockTracks.map(
+ ( block ) => block.attributes
+ );
+ const hasChanges = updatedTracks.some(
+ ( updatedTrack, index ) =>
+ ! tracks[ index ] ||
+ Object.keys( updatedTrack ).some(
+ ( key ) => updatedTrack[ key ] !== tracks[ index ]?.[ key ]
+ )
+ );
+ if ( hasChanges || updatedTracks.length !== tracks?.length ) {
+ __unstableMarkNextChangeAsNotPersistent();
+ setAttributes( {
+ tracks: updatedTracks,
+ currentTrack:
+ updatedTracks.length > 0 &&
+ // If the first track has changed, update the `currentTrack` block attribute.
+ updatedTracks[ 0 ].id !== currentTrack
+ ? updatedTracks[ 0 ].id
+ : currentTrack,
+ } );
+ }
+ }, [
+ __unstableMarkNextChangeAsNotPersistent,
+ clientId,
+ createErrorNotice,
+ currentTrack,
+ innerBlockTracks,
+ replaceInnerBlocks,
+ setAttributes,
+ tracks,
+ ] );
+
+ const onSelectTracks = useCallback(
+ ( media ) => {
+ if ( ! media ) {
+ return;
+ }
+
+ if ( ! Array.isArray( media ) ) {
+ media = [ media ];
+ }
+
+ const trackAttributes = ( track ) => ( {
+ id: track.id || track.url,
+ src: track.url,
+ } );
+
+ const trackList = media.map( trackAttributes );
+ __unstableMarkNextChangeAsNotPersistent();
+ setAttributes( {
+ tracks: trackList,
+ currentTrack: trackList.length > 0 ? trackList[ 0 ].id : null,
+ } );
+
+ const newBlocks = trackList.map( ( track ) =>
+ createBlock( 'core/playlist-track', track )
+ );
+ // Replace the inner blocks with the new tracks.
+ replaceInnerBlocks(
+ clientId,
+ innerBlockTracks.concat( newBlocks )
+ );
+ },
+ [
+ __unstableMarkNextChangeAsNotPersistent,
+ setAttributes,
+ replaceInnerBlocks,
+ clientId,
+ innerBlockTracks,
+ ]
+ );
+
+ const onTrackEnd = useCallback( () => {
+ /* If there are tracks left, play the next track */
+ if ( trackListIndex < tracks.length - 1 ) {
+ if ( tracks[ trackListIndex + 1 ]?.id ) {
+ setTrackListIndex( trackListIndex + 1 );
+ setAttributes( {
+ currentTrack: tracks[ trackListIndex + 1 ].id,
+ } );
+ }
+ } else {
+ setTrackListIndex( 0 );
+ if ( tracks[ 0 ].id ) {
+ setAttributes( { currentTrack: tracks[ 0 ].id } );
+ } else if ( tracks.length > 0 ) {
+ const validTrack = tracks.find(
+ ( track ) => track.id !== undefined
+ );
+ if ( validTrack ) {
+ setAttributes( { currentTrack: validTrack.id } );
+ }
+ }
+ }
+ }, [ setAttributes, trackListIndex, tracks ] );
+
+ const onChangeOrder = useCallback(
+ ( trackOrder ) => {
+ const sortedBlocks = [ ...innerBlockTracks ].sort( ( a, b ) => {
+ if ( trackOrder === 'ASC' ) {
+ return a.attributes.id - b.attributes.id;
+ }
+ return b.attributes.id - a.attributes.id;
+ } );
+ const sortedTracks = sortedBlocks.map(
+ ( block ) => block.attributes
+ );
+ replaceInnerBlocks( clientId, sortedBlocks );
+ setAttributes( {
+ order: trackOrder,
+ tracks: sortedTracks,
+ currentTrack:
+ sortedTracks.length > 0 &&
+ sortedTracks[ 0 ].id !== currentTrack
+ ? sortedTracks[ 0 ].id
+ : 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 ) }
+ />
+
+
+
+ >
+ );
+};
+
+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 0000000000000..e202eeca4cbe4
--- /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 0000000000000..6a6d898a036cc
--- /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 0000000000000..4c05cb03b5f06
--- /dev/null
+++ b/packages/block-library/src/playlist/index.php
@@ -0,0 +1,137 @@
+ function () {
+ $state = wp_interactivity_state();
+ $context = wp_interactivity_get_context();
+ return $state['tracks'][ $context['currentId'] ];
+ },
+ 'isCurrentTrack' => function () {
+ $context = wp_interactivity_get_context();
+ return $context['currentId'] === $context['id'];
+ },
+ )
+ );
+
+ // Finds the unique id of the current track and populates the playlist array.
+ $p = new WP_HTML_Tag_Processor( $content );
+ $playlist_tracks = array();
+ while ( $p->next_tag( 'button' ) ) {
+ $track_context = $p->get_attribute( 'data-wp-context' );
+ $track_unique_id = json_decode( $track_context, true )['id'];
+ $state = wp_interactivity_state( 'core/playlist' );
+ $playlist_tracks[] = $track_unique_id;
+ if (
+ isset( $state['tracks'][ $track_unique_id ]['media_id'] ) &&
+ $state['tracks'][ $track_unique_id ]['media_id'] === $current_media_id
+ ) {
+ $current_unique_id = $track_unique_id;
+ }
+ }
+
+ // Adds the markup for the current track.
+ $html = '';
+
+ if ( isset( $attributes['showImages'] ) ? $attributes['showImages'] : false ) {
+ $html .=
+ '
';
+ }
+
+ $html .= '
+
+
+
+ ';
+
+ $figure = null;
+ preg_match( '/