diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index 05ce3b0ef317e6..896d9056d29d7c 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -588,6 +588,15 @@ Show a block pattern. ([Source](https://github.com/WordPress/gutenberg/tree/trun
- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~
- **Attributes:** slug
+## Playlist
+
+Display an audio playlist. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/playlist))
+
+- **Name:** core/playlist
+- **Category:** media
+- **Supports:** align, anchor, interactivity (clientNavigation), spacing (margin, padding)
+- **Attributes:** autoplay, items, loop, showItemList
+
## 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 7d56a4a5df8819..fb502eba47de92 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -88,6 +88,7 @@ function gutenberg_reregister_core_block_types() {
'page-list.php' => 'core/page-list',
'page-list-item.php' => 'core/page-list-item',
'pattern.php' => 'core/pattern',
+ 'playlist.php' => 'core/playlist',
'post-author.php' => 'core/post-author',
'post-author-name.php' => 'core/post-author-name',
'post-author-biography.php' => 'core/post-author-biography',
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 1e2c9f1ff66362..a70ae7bdba054f 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -32,6 +32,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 feea74b786a4a3..7c11f1ebca01a0 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 f82df7d9a23ce0..233d3d3133ef20 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -108,6 +108,7 @@ import * as queryPaginationPrevious from './query-pagination-previous';
import * as queryTitle from './query-title';
import * as queryTotal from './query-total';
import * as quote from './quote';
+import * as playlist from './playlist';
import * as reusableBlock from './block';
import * as readMore from './read-more';
import * as rss from './rss';
@@ -152,6 +153,7 @@ const getAllBlocks = () => {
// Register all remaining core blocks.
archives,
audio,
+ playlist,
button,
buttons,
calendar,
diff --git a/packages/block-library/src/playlist/block.json b/packages/block-library/src/playlist/block.json
new file mode 100644
index 00000000000000..f6f89cc1abc014
--- /dev/null
+++ b/packages/block-library/src/playlist/block.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "core/playlist",
+ "title": "Playlist",
+ "category": "media",
+ "description": "Display an audio playlist.",
+ "keywords": [ "audio", "music", "sound", "tracks" ],
+ "textdomain": "default",
+ "attributes": {
+ "items": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "number"
+ }
+ },
+ "autoplay": {
+ "type": "boolean",
+ "default": false
+ },
+ "loop": {
+ "type": "boolean",
+ "default": false
+ },
+ "showItemList": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "supports": {
+ "anchor": true,
+ "align": true,
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true,
+ "__experimentalDefaultControls": {
+ "color": true,
+ "radius": false,
+ "style": true,
+ "width": true
+ }
+ },
+ "spacing": {
+ "margin": true,
+ "padding": true,
+ "__experimentalDefaultControls": {
+ "margin": false,
+ "padding": true
+ }
+ },
+ "interactivity": {
+ "clientNavigation": true
+ }
+ },
+ "editorStyle": "wp-block-playlist-editor",
+ "style": "wp-block-playlist",
+ "viewScriptModule": "@wordpress/block-library/playlist/view"
+}
diff --git a/packages/block-library/src/playlist/edit.js b/packages/block-library/src/playlist/edit.js
new file mode 100644
index 00000000000000..7f1e14b37c21f5
--- /dev/null
+++ b/packages/block-library/src/playlist/edit.js
@@ -0,0 +1,417 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ ToggleControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+ __experimentalHStack as HStack,
+ Spinner,
+ Button,
+} from '@wordpress/components';
+import {
+ BlockIcon,
+ InspectorControls,
+ MediaPlaceholder,
+ MediaReplaceFlow,
+ useBlockProps,
+ BlockControls,
+} from '@wordpress/block-editor';
+import { store as coreStore } from '@wordpress/core-data';
+import { __, sprintf } from '@wordpress/i18n';
+import { useDispatch, useSelect } from '@wordpress/data';
+import {
+ audio,
+ chevronUp,
+ chevronDown,
+ tableRowDelete,
+} from '@wordpress/icons';
+import { store as noticesStore } from '@wordpress/notices';
+import { useMemo, useState } from '@wordpress/element';
+import { isBlobURL } from '@wordpress/blob';
+
+/**
+ * Internal dependencies
+ */
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
+
+const ALLOWED_MEDIA_TYPES = [ 'audio' ];
+
+function PlaylistEdit( {
+ attributes: { items, autoplay, loop, showItemList },
+ setAttributes,
+} ) {
+ const [ currentItem, setCurrentItem ] = useState( 0 );
+ const { createErrorNotice } = useDispatch( noticesStore );
+ const itemsData = useSelect(
+ ( select ) => {
+ if ( ! items?.length ) {
+ return;
+ }
+ // We always create a set with the ids and sort them to avoid
+ // unnecessary requests when items change.
+ const uniqueItems = Array.from( new Set( items ) ).sort(
+ ( a, b ) => a - b
+ );
+ // TODO: how can we avoid this request if we already have the data?
+ // when removing an item for example.
+ return select( coreStore ).getEntityRecords(
+ 'postType',
+ 'attachment',
+ {
+ include: uniqueItems.join( ',' ),
+ per_page: -1,
+ orderby: 'include',
+ }
+ );
+ },
+ [ items ]
+ );
+ // Build items array because a playlist can contain the same item multiple times.
+ // Additionally we preserve the order of items that might be different in case of
+ // duplicates.
+ const orderedItemData = useMemo( () => {
+ if ( ! items?.length || ! itemsData?.length ) {
+ return;
+ }
+ return items
+ .map( ( itemId ) => itemsData.find( ( { id } ) => id === itemId ) )
+ .filter( Boolean );
+ }, [ items, itemsData ] );
+ const currentItemData = orderedItemData?.[ currentItem ];
+ function onSelectAudio( media ) {
+ if ( ! media ) {
+ return;
+ }
+
+ const mediaArray = Array.isArray( media ) ? media : [ media ];
+
+ // Skip intermediate calls with blob URLs (upload in progress)
+ const hasBlobURLs = mediaArray.some(
+ ( file ) => file.url && isBlobURL( file.url )
+ );
+ if ( hasBlobURLs ) {
+ return;
+ }
+
+ // Filter out invalid entries (missing url or id)
+ // MediaPlaceholder with allowedTypes already filters to audio files only.
+ const validMedia = mediaArray.filter( ( file ) => file.url && file.id );
+
+ if ( validMedia.length === 0 ) {
+ createErrorNotice( __( 'Please select valid audio files.' ), {
+ id: 'playlist-upload-invalid-file',
+ type: 'snackbar',
+ } );
+ return;
+ }
+
+ // Extract IDs from valid media
+ const newItemIds = validMedia.map( ( file ) => file.id );
+
+ setAttributes( {
+ items: [ ...( items || [] ), ...newItemIds ],
+ } );
+ }
+ function onUploadError( message ) {
+ createErrorNotice( message, { type: 'snackbar' } );
+ }
+ function removeItem( index ) {
+ const newItems = items.toSpliced( index, 1 );
+ setAttributes( {
+ items: newItems,
+ } );
+ if ( currentItem >= newItems.length ) {
+ setCurrentItem( Math.max( 0, newItems.length - 1 ) );
+ }
+ }
+ function moveItemUp( index ) {
+ const newItems = [ ...items ];
+ [ newItems[ index - 1 ], newItems[ index ] ] = [
+ newItems[ index ],
+ newItems[ index - 1 ],
+ ];
+ // Update current item only if we moved it.
+ if ( currentItem === index ) {
+ setCurrentItem( index - 1 );
+ }
+ setAttributes( { items: newItems } );
+ }
+ function moveItemDown( index ) {
+ const newItems = [ ...items ];
+ [ newItems[ index ], newItems[ index + 1 ] ] = [
+ newItems[ index + 1 ],
+ newItems[ index ],
+ ];
+ // Update current item only if we moved it.
+ if ( currentItem === index ) {
+ setCurrentItem( index + 1 );
+ }
+ setAttributes( { items: newItems } );
+ }
+ const blockProps = useBlockProps();
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+ if ( items?.length && ! orderedItemData ) {
+ return (
+
+ );
+ }
+ // TODO: check if it has items but there are no records in database..
+ if ( ! items?.length ) {
+ return (
+
+ }
+ labels={ {
+ title: __( 'Playlist' ),
+ instructions: __(
+ 'Drag and drop audio files, upload, or choose from your library.'
+ ),
+ } }
+ onSelect={ onSelectAudio }
+ accept="audio/*"
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ onError={ onUploadError }
+ />
+
+ );
+ }
+ return (
+ <>
+
+ { /* TODO: probably only allow single replace.. */ }
+
+
+
+ {
+ setAttributes( {
+ autoplay: false,
+ loop: false,
+ showItemList: true,
+ } );
+ } }
+ dropdownMenuProps={ dropdownMenuProps }
+ >
+ autoplay !== false }
+ label={ __( 'Autoplay' ) }
+ onDeselect={ () =>
+ setAttributes( { autoplay: false } )
+ }
+ isShownByDefault
+ >
+
+ setAttributes( { autoplay: value } )
+ }
+ help={
+ autoplay
+ ? __(
+ 'Autoplay may cause usability issues for some users.'
+ )
+ : null
+ }
+ />
+
+ loop !== false }
+ label={ __( 'Loop' ) }
+ onDeselect={ () => setAttributes( { loop: false } ) }
+ isShownByDefault
+ >
+
+ setAttributes( { loop: value } )
+ }
+ />
+
+ showItemList !== true }
+ label={ __( 'Show item list' ) }
+ onDeselect={ () =>
+ setAttributes( { showItemList: true } )
+ }
+ isShownByDefault
+ >
+
+ setAttributes( { showItemList: value } )
+ }
+ />
+
+
+
+
+
+ { currentItemData && (
+
+ { currentItemData.media_details?.sizes?.thumbnail
+ ?.source_url && (
+

+ ) }
+
+
+ { currentItemData.title?.rendered ||
+ currentItemData.title ||
+ __( 'Untitled' ) }
+
+ { currentItemData.media_details?.artist && (
+
+ { sprintf(
+ // translators: %s is the artist name.
+ __( 'by %s' ),
+ currentItemData.media_details.artist
+ ) }
+
+ ) }
+
+
+ ) }
+ { currentItemData?.source_url && (
+
+ ) }
+
+ { showItemList && !! orderedItemData?.length && (
+
+ { orderedItemData.map( ( item, index ) => (
+ // TODO: check this below..
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-element-interactions
+ - {
+ if ( currentItem !== index ) {
+ setCurrentItem( index );
+ }
+ } }
+ >
+
+
+ { index + 1 }.
+
+
+
+ { item.title?.rendered ||
+ item.title ||
+ __( 'Untitled' ) }
+
+ { item.media_details?.artist && (
+
+ { sprintf(
+ // translators: %s is the artist name.
+ __( 'by %s' ),
+ item.media_details.artist
+ ) }
+
+ ) }
+
+
+ { orderedItemData.length !== 1 && (
+ <>
+
+
+
+ ) ) }
+
+ ) }
+
+ >
+ );
+}
+
+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..99ba7a981f843f
--- /dev/null
+++ b/packages/block-library/src/playlist/editor.scss
@@ -0,0 +1,5 @@
+// TODO: better way to handle this..
+.wp-block-playlist__item-controls {
+ flex-shrink: 0;
+ margin-left: auto;
+}
diff --git a/packages/block-library/src/playlist/index.js b/packages/block-library/src/playlist/index.js
new file mode 100644
index 00000000000000..37edb43bc0ff84
--- /dev/null
+++ b/packages/block-library/src/playlist/index.js
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import { audio as icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import initBlock from '../utils/init-block';
+import edit from './edit';
+import metadata from './block.json';
+import save from './save';
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ icon,
+ example: {
+ attributes: {},
+ },
+ 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..9a17cff3a1e4e7
--- /dev/null
+++ b/packages/block-library/src/playlist/index.php
@@ -0,0 +1,235 @@
+ 'attachment',
+ 'post__in' => $items,
+ 'posts_per_page' => -1,
+ )
+ );
+
+ // Index attachments by ID for quick lookup.
+ $attachments_by_id = array();
+ foreach ( $attachments as $attachment ) {
+ $attachments_by_id[ $attachment->ID ] = $attachment;
+ }
+
+ // Build items data maintaining the order from $items array.
+ $items_data = array();
+ foreach ( $items as $item_id ) {
+ if ( ! isset( $attachments_by_id[ $item_id ] ) ) {
+ continue;
+ }
+
+ $attachment = $attachments_by_id[ $item_id ];
+
+ $item_info = array(
+ 'id' => $item_id,
+ 'url' => wp_get_attachment_url( $item_id ),
+ 'title' => get_the_title( $item_id ),
+ 'image' => '',
+ 'artist' => '',
+ );
+
+ // Get thumbnail if available.
+ // TODO: This should probably be a custom field on the attachment..
+ $thumbnail_id = get_post_thumbnail_id( $item_id );
+ if ( $thumbnail_id ) {
+ $thumbnail = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' );
+ if ( $thumbnail ) {
+ $item_info['image'] = $thumbnail[0];
+ }
+ }
+
+ // Get audio metadata (artist, etc).
+ $metadata = wp_get_attachment_metadata( $item_id );
+ if ( ! empty( $metadata['audio']['artist'] ) ) {
+ $item_info['artist'] = $metadata['audio']['artist'];
+ } elseif ( ! empty( $metadata['artist'] ) ) {
+ $item_info['artist'] = $metadata['artist'];
+ }
+
+ $items_data[] = $item_info;
+ }
+
+ if ( empty( $items_data ) ) {
+ return '';
+ }
+
+ // Current item is always the first item.
+ $current_item_data = $items_data[0];
+
+ // Build the context for Interactivity API.
+ $context = array(
+ 'items' => $items,
+ 'itemsData' => $items_data,
+ 'currentItem' => $current_item,
+ 'isPlaying' => false,
+ 'autoplay' => $autoplay,
+ 'loop' => $loop,
+ );
+
+ // Build header image.
+ $header_image = '';
+ if ( ! empty( $current_item_data['image'] ) ) {
+ $img = new WP_HTML_Tag_Processor(
+ sprintf(
+ '
',
+ esc_url( $current_item_data['image'] )
+ )
+ );
+ if ( $img->next_tag() ) {
+ $img->add_class( 'wp-block-playlist__header-image' );
+ $img->set_attribute( 'data-wp-bind--src', 'state.currentItemImage' );
+ }
+ $header_image = $img->get_updated_html();
+ }
+
+ // Build header artist.
+ $header_artist = '';
+ if ( ! empty( $current_item_data['artist'] ) ) {
+ $artist_text = sprintf(
+ /* translators: %s is the artist name. */
+ __( 'by %s' ),
+ esc_html( $current_item_data['artist'] )
+ );
+ $artist = new WP_HTML_Tag_Processor( sprintf( '', $artist_text ) );
+ if ( $artist->next_tag() ) {
+ $artist->set_attribute( 'data-wp-text', 'state.currentItemArtist' );
+ }
+ $header_artist = $artist->get_updated_html();
+ }
+
+ // Build header title.
+ $title = new WP_HTML_Tag_Processor( sprintf( '', esc_html( $current_item_data['title'] ) ) );
+ if ( $title->next_tag() ) {
+ $title->set_attribute( 'data-wp-text', 'state.currentItemTitle' );
+ }
+ $header_title = $title->get_updated_html();
+
+ // Build header.
+ $header = sprintf(
+ '',
+ $header_image,
+ $header_title,
+ $header_artist
+ );
+
+ // Build audio element.
+ $audio = new WP_HTML_Tag_Processor(
+ sprintf(
+ '',
+ esc_url( $current_item_data['url'] ),
+ $autoplay ? 'autoplay' : ''
+ )
+ );
+ if ( $audio->next_tag() ) {
+ $audio->add_class( 'wp-block-playlist__audio' );
+ $audio->set_attribute( 'data-wp-bind--src', 'state.currentItemSrc' );
+ $audio->set_attribute( 'data-wp-on--ended', 'actions.onItemEnded' );
+ $audio->set_attribute( 'data-wp-on--play', 'actions.onPlay' );
+ $audio->set_attribute( 'data-wp-on--pause', 'actions.onPause' );
+ }
+
+ // Build player.
+ $player = sprintf(
+ '%s%s',
+ $header,
+ $audio->get_updated_html()
+ );
+
+ // Build items list.
+ $items_list = '';
+ if ( $show_items ) {
+ $list_items = '';
+ foreach ( $items_data as $index => $item ) {
+ $item_classes = array( 'wp-block-playlist__item' );
+ if ( $index === 0 ) {
+ $item_classes[] = 'is-active';
+ }
+
+ $item_context = wp_json_encode( array( 'itemIndex' => $index ) );
+ $item_number = sprintf(
+ '%d.',
+ (int) $index + 1
+ );
+ $item_artist = '';
+ if ( ! empty( $item['artist'] ) ) {
+ $item_artist = sprintf(
+ '%s
',
+ sprintf(
+ /* translators: %s is the artist name. */
+ __( 'by %s' ),
+ esc_html( $item['artist'] )
+ )
+ );
+ }
+
+ $item_title = sprintf(
+ '',
+ esc_html( $item['title'] ),
+ $item_artist
+ );
+
+ $list_items .= sprintf(
+ '%s%s',
+ esc_attr( implode( ' ', $item_classes ) ),
+ $item_context,
+ $item_number,
+ $item_title
+ );
+ }
+ $items_list = sprintf( '%s
', $list_items );
+ }
+
+ // Build wrapper with interactivity directives.
+ $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => 'wp-block-playlist' ) );
+ $interactivity_context = wp_interactivity_data_wp_context( $context );
+
+ return sprintf(
+ '%s%s
',
+ $wrapper_attributes,
+ $interactivity_context,
+ $player,
+ $items_list
+ );
+}
+
+/**
+ * Registers the `core/playlist` block on the server.
+ */
+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/save.js b/packages/block-library/src/playlist/save.js
new file mode 100644
index 00000000000000..05bfd25df9f8b0
--- /dev/null
+++ b/packages/block-library/src/playlist/save.js
@@ -0,0 +1,8 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+export default function save( { attributes } ) {
+ return attributes.items?.length > 0 && ;
+}
diff --git a/packages/block-library/src/playlist/style.scss b/packages/block-library/src/playlist/style.scss
new file mode 100644
index 00000000000000..e5db07528acb22
--- /dev/null
+++ b/packages/block-library/src/playlist/style.scss
@@ -0,0 +1,111 @@
+.wp-block-playlist {
+ display: flex;
+ flex-direction: column;
+ gap: $grid-unit-15;
+ justify-content: center;
+
+ .wp-block-playlist__player {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: $grid-unit-15;
+
+ .wp-block-playlist__header {
+ display: flex;
+ flex-direction: row;
+ gap: $grid-unit-05;
+ align-items: flex-start;
+
+ .wp-block-playlist__header-image {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 4px;
+ flex-shrink: 0;
+ }
+
+ .wp-block-playlist__header-info {
+ display: flex;
+ flex-direction: column;
+ gap: $grid-unit-05;
+ min-width: 0;
+
+ .wp-block-playlist__header-title {
+ font-weight: $font-weight-medium;
+ font-size: $font-size-large;
+ }
+
+ .wp-block-playlist__header-subtitle {
+ font-size: $font-size-medium;
+ color: $gray-700;
+ }
+ }
+ }
+
+ .wp-block-playlist__audio {
+ width: 100%;
+ }
+ }
+
+ .wp-block-playlist__items {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+
+ .wp-block-playlist__item {
+ display: flex;
+ flex-direction: row;
+ gap: $grid-unit-20;
+ width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ padding: $grid-unit-10;
+ background: transparent;
+ transition: background-color 0.2s ease;
+ cursor: pointer;
+ box-sizing: border-box;
+
+ & + .wp-block-playlist__item {
+ border-top: 1px solid $gray-300;
+ }
+
+ &:hover {
+ background: $gray-100;
+ }
+
+ &.is-active {
+ background: $gray-100;
+
+ .wp-block-playlist__item-title {
+ font-weight: $font-weight-medium;
+ }
+ }
+
+ .wp-block-playlist__item-number {
+ font-size: $font-size-medium;
+ flex-shrink: 0;
+ }
+
+ .wp-block-playlist__item-info {
+ display: flex;
+ flex-shrink: 0;
+ flex-direction: column;
+ justify-content: center;
+ gap: $grid-unit-05;
+
+ .wp-block-playlist__item-title {
+ font-size: $font-size-large;
+ line-height: 1.3;
+ text-wrap: balance; /* Fallback for Safari. */
+ text-wrap: pretty;
+ flex-shrink: 0;
+ }
+
+ .wp-block-playlist__item-artist {
+ font-size: $font-size-medium;
+ color: $gray-700;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/block-library/src/playlist/view.js b/packages/block-library/src/playlist/view.js
new file mode 100644
index 00000000000000..d12e2148fc54da
--- /dev/null
+++ b/packages/block-library/src/playlist/view.js
@@ -0,0 +1,120 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getContext, getElement } from '@wordpress/interactivity';
+
+const { state } = store( 'core/playlist', {
+ state: {
+ get currentItemData() {
+ const context = getContext();
+ const { itemsData, currentItem } = context;
+
+ if ( ! itemsData || ! itemsData[ currentItem ] ) {
+ return null;
+ }
+
+ return itemsData[ currentItem ];
+ },
+ get currentItemSrc() {
+ const itemData = state.currentItemData;
+ return itemData?.url || '';
+ },
+ get currentItemTitle() {
+ const itemData = state.currentItemData;
+ return itemData?.title || '';
+ },
+ get currentItemArtist() {
+ const itemData = state.currentItemData;
+ if ( ! itemData?.artist ) {
+ return '';
+ }
+ // translators: %s is the artist name.
+ return `by ${ itemData.artist }`;
+ },
+ get currentItemImage() {
+ const itemData = state.currentItemData;
+ return itemData?.image || '';
+ },
+ get isItemActive() {
+ const context = getContext();
+ const parentContext = getContext( 'core/playlist' );
+ const { itemIndex } = context;
+ return itemIndex === parentContext.currentItem;
+ },
+ },
+ actions: {
+ selectItem: () => {
+ const context = getContext();
+ const { itemIndex } = context;
+
+ // Get the parent context and update current item
+ const parentContext = getContext( 'core/playlist' );
+ const wasPlaying = parentContext.isPlaying;
+ parentContext.currentItem = itemIndex;
+
+ // Get the audio element and play the new item if needed
+ const { ref } = getElement();
+ const container = ref.closest(
+ '[data-wp-interactive="core/playlist"]'
+ );
+ if ( container ) {
+ const audio = container.querySelector(
+ '.wp-block-playlist__audio'
+ );
+ if ( audio ) {
+ audio.load();
+ if ( wasPlaying ) {
+ audio.play();
+ }
+ }
+ }
+ },
+ onItemEnded: () => {
+ const context = getContext();
+ const { itemsData, currentItem, loop } = context;
+
+ // If not the last item, move to next.
+ if ( currentItem < itemsData.length - 1 ) {
+ context.currentItem = currentItem + 1;
+ const { ref } = getElement();
+ if ( ref ) {
+ ref.load();
+ ref.play();
+ }
+ } else if ( loop ) {
+ // Last item and loop enabled - go back to first item.
+ context.currentItem = 0;
+ const { ref } = getElement();
+ if ( ref ) {
+ ref.load();
+ ref.play();
+ }
+ }
+ // If last item and no loop - do nothing (stop playing).
+ },
+ onPlay: () => {
+ const context = getContext();
+ context.isPlaying = true;
+ },
+ onPause: () => {
+ const context = getContext();
+ context.isPlaying = false;
+ },
+ },
+ callbacks: {
+ updateAudio: () => {
+ const context = getContext();
+ const { autoplay, currentItem } = context;
+
+ const { ref } = getElement();
+ if ( ! ref ) {
+ return;
+ }
+
+ const audio = ref.querySelector( '.wp-block-playlist__audio' );
+ if ( audio && autoplay && currentItem === 0 ) {
+ audio.play();
+ }
+ },
+ },
+} );
diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss
index 1ea2158cc38692..41c0555da24798 100644
--- a/packages/block-library/src/style.scss
+++ b/packages/block-library/src/style.scss
@@ -36,6 +36,7 @@
@import "./navigation-link/style.scss";
@import "./page-list/style.scss";
@import "./paragraph/style.scss";
+@import "./playlist/style.scss";
@import "./post-author/style.scss";
@import "./post-author-biography/style.scss";
@import "./post-comments-form/style.scss";
diff --git a/test/integration/fixtures/blocks/core__playlist.html b/test/integration/fixtures/blocks/core__playlist.html
new file mode 100644
index 00000000000000..5ed41fb1baaab9
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__playlist.html
@@ -0,0 +1 @@
+
diff --git a/test/integration/fixtures/blocks/core__playlist.json b/test/integration/fixtures/blocks/core__playlist.json
new file mode 100644
index 00000000000000..d4bfa0b5f30be9
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__playlist.json
@@ -0,0 +1,13 @@
+[
+ {
+ "name": "core/playlist",
+ "isValid": true,
+ "attributes": {
+ "items": [],
+ "autoplay": false,
+ "loop": false,
+ "showItemList": true
+ },
+ "innerBlocks": []
+ }
+]
diff --git a/test/integration/fixtures/blocks/core__playlist.parsed.json b/test/integration/fixtures/blocks/core__playlist.parsed.json
new file mode 100644
index 00000000000000..fd095323d526ab
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__playlist.parsed.json
@@ -0,0 +1,9 @@
+[
+ {
+ "blockName": "core/playlist",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "",
+ "innerContent": []
+ }
+]
diff --git a/test/integration/fixtures/blocks/core__playlist.serialized.html b/test/integration/fixtures/blocks/core__playlist.serialized.html
new file mode 100644
index 00000000000000..5ed41fb1baaab9
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__playlist.serialized.html
@@ -0,0 +1 @@
+