Commit 3c32e44ffc1 for woocommerce
commit 3c32e44ffc1e1048145a1f9e4026522992ab89dd
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Wed Jul 8 08:42:55 2026 +0200
Product gallery videos #1: Add internal product video gallery storage (#65396)
* Add product media gallery data model
* Internalize product media gallery data model
* Use video-only product gallery storage
* Clarify product gallery video positions
* Clarify video gallery position example
* Rename positioned video merge helper
* Clarify positioned video gallery extraction
* Add product media gallery position tests
* Remove public product gallery videos helper
* Remove product media gallery feature override
* Validate stored product gallery videos
* Remove arbitrary video gallery data preservation
* Always validate product gallery media items
* Fix product gallery video test alignment
* Fix product gallery video PHPStan warnings
* Update product gallery videos feature copy
* Gate video poster attachment response by feature flag
* Sanitize video gallery fallback positions
* Add deleted image video gallery position test
* Remove beta label from product gallery videos feature
* Remove unused video gallery settings normalization
* Product gallery videos #2: Add product gallery video support in product editor (#65398)
* Add product gallery video authoring
* Use internal media gallery storage in product authoring
* Trigger product editor checks
* Store product gallery videos separately
* Update product gallery video admin helpers
* Use real attachments in video gallery tests
* Improve product gallery media item construction
* Simplify product gallery media item rendering
* Update video gallery test expectations
* Product gallery videos #3: Render videos in classic product galleries (#65399)
* Render videos in classic product galleries
* Fix classic gallery video sizing
* Remove duplicated classic gallery video CSS
* Update product media gallery helper docs
* Use default cursor for classic gallery videos
* Restore classic gallery media lightbox clicks
* Mark classic gallery video helpers internal
* Move classic gallery video styles to CSS
* Fix classic gallery video thumbnail previews
* Move classic gallery video helpers into internal class
* Bump product gallery template versions
* Product gallery videos #4: Render videos in Product Gallery blocks (#65400)
* Render videos in Product Gallery blocks
* Use internal media gallery storage in block tests
* Use video gallery storage in block tests
* Improve block gallery video rendering
* Simplify product gallery video replacement
* fix: update call site of global functions now internal
* Play dialog videos only when visible
* Use iAPI init for dialog video observers
* Fix dialog video playback visibility handling
* Add test for product gallery video replacement
* Fix product gallery video CI failures
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
diff --git a/plugins/woocommerce/changelog/add-product-gallery-videos b/plugins/woocommerce/changelog/add-product-gallery-videos
new file mode 100644
index 00000000000..21155de7789
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-gallery-videos
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add beta support for videos in product galleries.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
index abb92f0898b..a7e8a1d67ad 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
@@ -10,6 +10,7 @@ export const SELECTORS = {
galleryContainer: '.wp-block-woocommerce-product-gallery',
largeImageContainer: '.wc-block-product-gallery-large-image__container',
largeImageWrapper: '.wc-block-product-gallery-large-image__wrapper',
+ dialogContent: '.wc-block-product-gallery-dialog__content',
thumbnail: '.wc-block-product-gallery-thumbnails__thumbnail',
thumbnailsScrollable: '.wc-block-product-gallery-thumbnails__scrollable',
legacyVariationIdInput: 'input[name="variation_id"]',
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
index a202407c805..a317443cecf 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
@@ -36,6 +36,89 @@ const getArrowsState = ( imageIndex: number, totalImages: number ) => ( {
isDisabledNext: imageIndex === totalImages - 1,
} );
+const DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO = 0.25;
+
+const getVerticalIntersectionRatio = ( rect: DOMRect, rootRect: DOMRect ) => {
+ const height =
+ Math.min( rect.bottom, rootRect.bottom ) -
+ Math.max( rect.top, rootRect.top );
+ const referenceHeight = Math.min( rect.height, rootRect.height );
+
+ return referenceHeight ? Math.max( 0, height ) / referenceHeight : 0;
+};
+
+const isDialogVideoInView = ( video: HTMLVideoElement ) => {
+ const scrollableContainer = video.closest( SELECTORS.dialogContent );
+
+ if ( ! scrollableContainer ) {
+ return false;
+ }
+
+ const videoRect = video.getBoundingClientRect();
+ const containerRect = scrollableContainer.getBoundingClientRect();
+
+ return (
+ getVerticalIntersectionRatio( videoRect, containerRect ) >=
+ DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO
+ );
+};
+
+const isDialogVideoIntersectionInView = (
+ entry: IntersectionObserverEntry
+) => {
+ if ( ! entry.rootBounds || ! entry.boundingClientRect.height ) {
+ return false;
+ }
+
+ return (
+ getVerticalIntersectionRatio(
+ entry.boundingClientRect,
+ entry.rootBounds
+ ) >= DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO
+ );
+};
+
+const syncVideoPlaybackState = (
+ video: HTMLVideoElement,
+ shouldPlay: boolean
+) => {
+ if ( shouldPlay && video.paused ) {
+ void video.play().catch( () => undefined );
+ } else if ( ! shouldPlay && ! video.paused ) {
+ video.pause();
+ }
+};
+
+const syncVideoElementPlayback = (
+ video: HTMLVideoElement,
+ selectedImageId: number,
+ isDialogOpen: boolean,
+ videoLocation?: ProductGalleryContext[ 'videoLocation' ]
+) => {
+ const imageId = Number( video.getAttribute( 'data-image-id' ) ?? 0 );
+ const shouldPlayInDialog =
+ videoLocation === 'dialog' &&
+ isDialogOpen &&
+ isDialogVideoInView( video );
+ const shouldPlayInGallery =
+ videoLocation === 'gallery' &&
+ selectedImageId === imageId &&
+ ! isDialogOpen;
+ const shouldPlay = shouldPlayInDialog || shouldPlayInGallery;
+
+ syncVideoPlaybackState( video, shouldPlay );
+};
+
+const syncScopedVideoElementPlayback = ( video: HTMLVideoElement ) => {
+ const { selectedImageId, isDialogOpen, videoLocation } = getContext();
+ syncVideoElementPlayback(
+ video,
+ selectedImageId,
+ isDialogOpen,
+ videoLocation
+ );
+};
+
/** Read the `products` map from the WooCommerce iAPI config (or `{}`). */
const getConfiguredProducts = () =>
( getConfig( 'woocommerce' ) as ProductGalleryConfig )?.products || {};
@@ -237,7 +320,7 @@ const scrollThumbnailIntoView = ( imageId: number ) => {
}
const thumbnailElement = galleryContainer.querySelector(
- `${ SELECTORS.thumbnail } ${ SELECTORS.imgByImageId( imageId ) }`
+ `${ SELECTORS.thumbnail } ${ SELECTORS.elementByImageId( imageId ) }`
);
if ( ! thumbnailElement ) {
@@ -544,7 +627,7 @@ const productGallery = {
);
if ( galleryContainer ) {
const selectedImage = galleryContainer.querySelector(
- SELECTORS.imgByImageId( selectedImageId )
+ SELECTORS.elementByImageId( selectedImageId )
) as HTMLElement;
if ( selectedImage ) {
selectedImage.focus( { preventScroll: true } );
@@ -723,7 +806,11 @@ const productGallery = {
const { selectedImageId, isDialogOpen } = getContext();
const { ref: dialogRef } = getElement() || {};
- if ( isDialogOpen && dialogRef instanceof HTMLElement ) {
+ if ( ! ( dialogRef instanceof HTMLElement ) ) {
+ return;
+ }
+
+ if ( isDialogOpen ) {
dialogRef.focus();
const selectedImage = dialogRef.querySelector(
SELECTORS.elementByImageId( selectedImageId )
@@ -743,6 +830,61 @@ const productGallery = {
}
}
},
+ initDialogVideoPlayback: () => {
+ const element = getElement()?.ref;
+
+ if ( ! ( element instanceof HTMLVideoElement ) ) {
+ return;
+ }
+
+ const scrollableContainer = element.closest(
+ SELECTORS.dialogContent
+ );
+
+ if ( ! scrollableContainer || ! window.IntersectionObserver ) {
+ return () => element.pause();
+ }
+
+ const observer = new IntersectionObserver(
+ ( entries: IntersectionObserverEntry[] ) => {
+ entries.forEach( ( entry ) => {
+ if ( entry.target !== element ) {
+ return;
+ }
+
+ const isDialogOpen = document.body.classList.contains(
+ CLASSES.dialogOpenBody
+ );
+ const shouldPlay =
+ isDialogOpen &&
+ isDialogVideoIntersectionInView( entry );
+
+ syncVideoPlaybackState( element, shouldPlay );
+ } );
+ },
+ {
+ root: scrollableContainer,
+ threshold: [ 0, DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO, 1 ],
+ }
+ );
+
+ observer.observe( element );
+
+ return () => {
+ observer.disconnect();
+ element.pause();
+ };
+ },
+ syncVideoPlayback: () => {
+ const element = getElement()?.ref;
+
+ if ( ! ( element instanceof HTMLVideoElement ) ) {
+ return;
+ }
+
+ toggleImageVisibility( element );
+ syncScopedVideoElementPlayback( element );
+ },
/** Per-image `data-wp-watch` callback that toggles visibility from `imageData`. */
toggleImageVisibility: () => {
const element = getElement()?.ref as HTMLElement;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
index efcf228d2b2..b54d339bde1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
@@ -54,14 +54,15 @@ $dialog-padding: 20px;
height: 100%;
}
- img {
+ img,
+ video {
height: 100%;
}
- &.wc-block-components-product-image--aspect-ratio-auto img {
+ &.wc-block-components-product-image--aspect-ratio-auto img,
+ &.wc-block-components-product-image--aspect-ratio-auto video {
object-fit: contain !important;
}
-
}
:where(.wc-block-woocommerce-product-gallery-large-image__image) {
@@ -79,6 +80,13 @@ $dialog-padding: 20px;
}
}
+ :where(.wc-block-woocommerce-product-gallery-large-image__video) {
+ height: 100%;
+ max-width: 100%;
+ object-fit: contain;
+ width: 100%;
+ }
+
:where(.wc-block-product-gallery-large-image__inner-blocks) {
display: flex;
flex-direction: column;
@@ -251,6 +259,20 @@ $dialog-padding: 20px;
:where(.wc-block-product-gallery-thumbnails__thumbnail) {
display: flex;
+ position: relative;
+}
+
+:where(.wc-block-product-gallery-thumbnails__thumbnail--video)::after {
+ content: "\25B6";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ color: var(--wp--preset--color--base, #fff);
+ line-height: 1;
+ text-shadow: 0 0 0.25em rgb(0 0 0 / 80%);
+ pointer-events: none;
}
.wc-block-product-gallery-thumbnails__thumbnail[hidden] {
@@ -347,7 +369,8 @@ $dialog-padding: 20px;
height: calc(100vh - 64px - $admin-bar-height);
}
- img {
+ img,
+ video {
max-width: 1600px;
width: 100%;
height: auto;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
index 035c54276d3..5f8730cf4f2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
@@ -53,6 +53,7 @@ export type LegacyJQueryFormHandlers = {
export interface ProductGalleryContext {
selectedImageId: number;
isDialogOpen: boolean;
+ videoLocation?: 'dialog' | 'gallery';
productId: string;
touchStartX: number;
touchCurrentX: number;
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 2cdc23f0d3e..219e79b1846 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -5684,6 +5684,30 @@ img.help_tip {
height: auto;
display: block;
}
+
+ .woocommerce-product-gallery__video-preview {
+ display: block;
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ background: $gray-100;
+ }
+
+ &.video::before {
+ @include icon_dashicons("\f522");
+
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ width: auto;
+ height: auto;
+ color: $contentbg;
+ font-size: 1.4em;
+ line-height: 1;
+ text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.8);
+ pointer-events: none;
+ }
}
li.wc-metabox-sortable-placeholder {
diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss
index b72a8e1d409..b14ae038e93 100644
--- a/plugins/woocommerce/client/legacy/css/woocommerce.scss
+++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss
@@ -52,6 +52,23 @@ p.demo_store,
top: 32px;
}
+.woocommerce-product-gallery__photoswipe-video-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+.woocommerce-product-gallery__photoswipe-video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+}
+
/**
* Utility classes
*/
@@ -190,6 +207,12 @@ p.demo_store,
outline-offset: -2px;
}
+ .woocommerce-product-gallery__video .wp-post-video {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+
.woocommerce-product-gallery__trigger {
@include woocommerce-product-gallery__trigger;
}
@@ -205,8 +228,10 @@ p.demo_store,
float: left;
margin: 0;
list-style: none;
+ position: relative;
- img {
+ img,
+ .woocommerce-product-gallery__video-thumbnail-preview {
cursor: pointer;
opacity: 0.5;
margin: 0;
@@ -216,6 +241,42 @@ p.demo_store,
opacity: 1;
}
}
+
+ &.woocommerce-product-gallery__video-thumbnail {
+ &::before {
+ content: "\25B6";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ color: $contentbg;
+ font-size: 1.4em;
+ text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.8);
+ pointer-events: none;
+ }
+
+ img.woocommerce-product-gallery__video-thumbnail-placeholder,
+ img.woocommerce-product-gallery__video-thumbnail-placeholder.flex-active,
+ img.woocommerce-product-gallery__video-thumbnail-placeholder:hover {
+ opacity: 0;
+ }
+
+ .woocommerce-product-gallery__video-thumbnail-preview {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ pointer-events: none;
+ }
+
+ &:hover .woocommerce-product-gallery__video-thumbnail-preview,
+ .woocommerce-product-gallery__video-thumbnail-placeholder.flex-active
+ + .woocommerce-product-gallery__video-thumbnail-preview {
+ opacity: 1;
+ }
+ }
}
}
}
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
index 75343c67b12..4759e9c9288 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
@@ -1306,14 +1306,148 @@ jQuery( function ( $ ) {
} );
// Product gallery file uploads.
- var product_gallery_frame;
- var $image_gallery_ids = $( '#product_image_gallery' );
- var $product_images = $( '#product_images_container' ).find(
+ let product_gallery_frame;
+ const $image_gallery_ids = $( '#product_image_gallery' );
+ const $media_gallery = $( '#product_media_gallery' );
+ const $product_images = $( '#product_images_container' ).find(
'ul.product_images'
);
+ function getProductGalleryImageSrc( attachment ) {
+ return attachment.sizes && attachment.sizes.thumbnail
+ ? attachment.sizes.thumbnail.url
+ : attachment.url;
+ }
+
+ function addProductGalleryMediaItemActions( $item, $el ) {
+ $item.append(
+ $( '<ul />', {
+ class: 'actions',
+ } ).append(
+ $( '<li />' ).append(
+ $( '<a />', {
+ href: '#',
+ class: 'delete tips',
+ title: $el.data( 'delete' ),
+ 'data-tip': $el.data( 'delete' ),
+ text: $el.data( 'text' ),
+ } )
+ )
+ )
+ );
+
+ return $item;
+ }
+
+ function createProductGalleryImageItem( attachment, $el ) {
+ const attachmentImage = getProductGalleryImageSrc( attachment );
+
+ if ( ! attachment.id || ! attachmentImage ) {
+ return null;
+ }
+
+ const $item = $( '<li />', {
+ class: 'image',
+ } )
+ .attr( 'data-attachment_id', attachment.id )
+ .attr( 'data-media_type', 'image' )
+ .attr( 'data-source_type', 'attachment' );
+
+ $item.append(
+ $( '<img />', {
+ src: attachmentImage,
+ } )
+ );
+
+ return addProductGalleryMediaItemActions( $item, $el );
+ }
+
+ function createProductGalleryVideoItem( attachment, $el ) {
+ const posterId = parseInt( attachment.poster_id, 10 ) || 0;
+
+ if ( ! attachment.id || ! attachment.url ) {
+ return null;
+ }
+
+ const $item = $( '<li />', {
+ class: 'image video',
+ } )
+ .attr( 'data-attachment_id', attachment.id )
+ .attr( 'data-media_type', 'video' )
+ .attr( 'data-source_type', 'attachment' )
+ .attr( 'data-poster_id', posterId );
+
+ $item.append(
+ $( '<video />', {
+ class: 'woocommerce-product-gallery__video-preview',
+ src: attachment.url,
+ preload: 'metadata',
+ muted: 'muted',
+ 'aria-hidden': 'true',
+ } )
+ );
+
+ return addProductGalleryMediaItemActions( $item, $el );
+ }
+
+ function createProductGalleryMediaItem( attachment, $el, allowVideos ) {
+ const mediaType = attachment.type;
+
+ if ( 'image' === mediaType ) {
+ return createProductGalleryImageItem( attachment, $el );
+ }
+
+ if ( allowVideos && 'video' === mediaType ) {
+ return createProductGalleryVideoItem( attachment, $el );
+ }
+
+ return null;
+ }
+
+ function getProductGalleryMediaItemData( $item ) {
+ const attachmentId = parseInt( $item.attr( 'data-attachment_id' ), 10 );
+ const mediaType = $item.attr( 'data-media_type' ) || 'image';
+
+ if ( ! attachmentId ) {
+ return null;
+ }
+
+ const mediaItem = {
+ media_type: mediaType,
+ source_type: 'attachment',
+ id: attachmentId,
+ };
+
+ if ( 'video' === mediaType ) {
+ const posterId = parseInt( $item.attr( 'data-poster_id' ), 10 );
+
+ if ( posterId ) {
+ mediaItem.poster_id = posterId;
+ }
+ }
+
+ return mediaItem;
+ }
+
+ function updateProductGalleryFields() {
+ const mediaGallery = $( '#product_images_container' )
+ .find( 'ul li.image' )
+ .css( 'cursor', 'default' )
+ .map( function () {
+ return getProductGalleryMediaItemData( $( this ) );
+ } )
+ .get();
+ const attachmentIds = mediaGallery
+ .filter( ( mediaItem ) => 'image' === mediaItem.media_type )
+ .map( ( mediaItem ) => mediaItem.id );
+
+ $image_gallery_ids.val( attachmentIds.join( ',' ) );
+ $media_gallery.val( JSON.stringify( mediaGallery ) );
+ }
+
$( '.add_product_images' ).on( 'click', 'a', function ( event ) {
- var $el = $( this );
+ const $el = $( this );
+ const allowVideos = 'yes' === $el.data( 'allow_videos' );
event.preventDefault();
@@ -1333,51 +1467,40 @@ jQuery( function ( $ ) {
states: [
new wp.media.controller.Library( {
title: $el.data( 'choose' ),
+ library: wp.media.query( {
+ type: allowVideos ? [ 'image', 'video' ] : 'image',
+ } ),
filterable: 'all',
multiple: true,
} ),
],
} );
- // When an image is selected, run a callback.
+ // When media is selected, run a callback.
product_gallery_frame.on( 'select', function () {
- var selection = product_gallery_frame.state().get( 'selection' );
- var attachment_ids = $image_gallery_ids.val();
+ const selection = product_gallery_frame.state().get( 'selection' );
selection.map( function ( attachment ) {
- attachment = attachment.toJSON();
+ const attachmentData = attachment.toJSON();
+ const $item = createProductGalleryMediaItem(
+ attachmentData,
+ $el,
+ allowVideos
+ );
- if ( attachment.id ) {
- attachment_ids = attachment_ids
- ? attachment_ids + ',' + attachment.id
- : attachment.id;
- var attachment_image =
- attachment.sizes && attachment.sizes.thumbnail
- ? attachment.sizes.thumbnail.url
- : attachment.url;
-
- $product_images.append(
- '<li class="image" data-attachment_id="' +
- attachment.id +
- '"><img src="' +
- attachment_image +
- '" /><ul class="actions"><li><a href="#" class="delete" title="' +
- $el.data( 'delete' ) +
- '">' +
- $el.data( 'text' ) +
- '</a></li></ul></li>'
- );
+ if ( $item ) {
+ $product_images.append( $item );
}
} );
- $image_gallery_ids.val( attachment_ids );
+ updateProductGalleryFields();
} );
// Finally, open the modal.
product_gallery_frame.open();
} );
- // Image ordering.
+ // Media ordering.
$product_images.sortable( {
items: 'li.image',
cursor: 'move',
@@ -1393,36 +1516,14 @@ jQuery( function ( $ ) {
stop: function ( event, ui ) {
ui.item.removeAttr( 'style' );
},
- update: function () {
- var attachment_ids = '';
-
- $( '#product_images_container' )
- .find( 'ul li.image' )
- .css( 'cursor', 'default' )
- .each( function () {
- var attachment_id = $( this ).attr( 'data-attachment_id' );
- attachment_ids = attachment_ids + attachment_id + ',';
- } );
-
- $image_gallery_ids.val( attachment_ids );
- },
+ update: updateProductGalleryFields,
} );
- // Remove images.
+ // Remove media.
$( '#product_images_container' ).on( 'click', 'a.delete', function () {
$( this ).closest( 'li.image' ).remove();
- var attachment_ids = '';
-
- $( '#product_images_container' )
- .find( 'ul li.image' )
- .css( 'cursor', 'default' )
- .each( function () {
- var attachment_id = $( this ).attr( 'data-attachment_id' );
- attachment_ids = attachment_ids + attachment_id + ',';
- } );
-
- $image_gallery_ids.val( attachment_ids );
+ updateProductGalleryFields();
// Remove any lingering tooltips.
$( '#tiptip_holder' ).removeAttr( 'style' );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/single-product.js b/plugins/woocommerce/client/legacy/js/frontend/single-product.js
index ecccfcd9dcc..16b2027a215 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/single-product.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/single-product.js
@@ -238,6 +238,10 @@ jQuery( function( $ ) {
this.initZoom = this.initZoom.bind( this );
this.initZoomForTarget = this.initZoomForTarget.bind( this );
this.initPhotoswipe = this.initPhotoswipe.bind( this );
+ this.initVideoThumbnailPreviews = this.initVideoThumbnailPreviews.bind( this );
+ this.syncVideoPlayback = this.syncVideoPlayback.bind( this );
+ this.getPhotoswipeVideoHtml = this.getPhotoswipeVideoHtml.bind( this );
+ this.syncPhotoswipeVideoPlayback = this.syncPhotoswipeVideoPlayback.bind( this );
this.onResetSlidePosition = this.onResetSlidePosition.bind( this );
this.getGalleryItems = this.getGalleryItems.bind( this );
this.openPhotoswipe = this.openPhotoswipe.bind( this );
@@ -249,6 +253,7 @@ jQuery( function( $ ) {
$target.on( 'woocommerce_gallery_reset_slide_position', this.onResetSlidePosition );
} else {
this.$target.css( 'opacity', 1 );
+ this.syncVideoPlayback( 0 );
}
if ( this.zoom_enabled ) {
@@ -270,15 +275,28 @@ jQuery( function( $ ) {
var options = $.extend( {
selector: '.woocommerce-product-gallery__wrapper > .woocommerce-product-gallery__image',
- start: function() {
+ start: function( slider ) {
$target.css( 'opacity', 1 );
+ gallery.syncVideoPlayback( slider.currentSlide );
+ gallery.syncVideoThumbnailPreviewOpacity();
},
after: function( slider ) {
gallery.initZoomForTarget( gallery.$images.eq( slider.currentSlide ) );
+ gallery.syncVideoPlayback( slider.currentSlide );
+ gallery.syncVideoThumbnailPreviewOpacity();
}
}, args );
$target.flexslider( options );
+ gallery.initVideoThumbnailPreviews();
+ $target
+ .off( 'click.wcProductGalleryVideoThumbs', '.flex-control-thumbs img' )
+ .on( 'click.wcProductGalleryVideoThumbs', '.flex-control-thumbs img', function() {
+ window.setTimeout( function() {
+ gallery.syncVideoPlayback();
+ gallery.syncVideoThumbnailPreviewOpacity();
+ }, 0 );
+ } );
// Trigger resize after main image loads to ensure correct gallery size.
$( '.woocommerce-product-gallery__wrapper .woocommerce-product-gallery__image:eq(0) .wp-post-image' ).one( 'load', function() {
@@ -301,6 +319,96 @@ jQuery( function( $ ) {
} );
};
+ /**
+ * Play only the active gallery video.
+ */
+ ProductGallery.prototype.syncVideoPlayback = function( slideIndex ) {
+ const $activeSlide = 'number' === typeof slideIndex
+ ? this.$images.eq( slideIndex )
+ : this.$target.find( '.flex-active-slide' ).first();
+
+ this.$images.find( 'video.wp-post-video' ).each( function() {
+ if ( $activeSlide.has( this ).length ) {
+ const playPromise = this.play();
+
+ if ( playPromise && playPromise.catch ) {
+ playPromise.catch( function() {
+ return undefined;
+ } );
+ }
+ } else {
+ this.pause();
+ }
+ } );
+ };
+
+ /**
+ * Show browser-rendered previews for video thumbnails without posters.
+ */
+ ProductGallery.prototype.initVideoThumbnailPreviews = function() {
+ const $thumbs = this.$target.find( '.flex-control-thumbs img' );
+
+ if ( ! $thumbs.length ) {
+ return;
+ }
+
+ this.$images.each( function( index, slide ) {
+ const videoSrc = $( slide ).attr( 'data-thumb-video-src' );
+ const $thumb = $thumbs.eq( index );
+ const $thumbItem = $thumb.closest( 'li' );
+
+ if ( ! videoSrc || ! $thumb.length || $thumb.siblings( 'video' ).length ) {
+ return;
+ }
+
+ const video = $( '<video />', {
+ class: 'woocommerce-product-gallery__video-thumbnail-preview',
+ src: videoSrc,
+ preload: 'metadata',
+ muted: 'muted',
+ playsinline: 'playsinline',
+ 'aria-hidden': 'true',
+ } ).css( {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ opacity: 0.5,
+ pointerEvents: 'none',
+ } );
+
+ $thumbItem
+ .addClass( 'woocommerce-product-gallery__video-thumbnail' )
+ .css( 'position', 'relative' );
+ $thumb
+ .addClass( 'woocommerce-product-gallery__video-thumbnail-placeholder' )
+ .css( 'opacity', 0 )
+ .after( video );
+ } );
+
+ this.syncVideoThumbnailPreviewOpacity();
+ };
+
+ /**
+ * Sync video thumbnail preview opacity with FlexSlider active state.
+ */
+ ProductGallery.prototype.syncVideoThumbnailPreviewOpacity = function() {
+ this.$target
+ .find( '.flex-control-thumbs li.woocommerce-product-gallery__video-thumbnail' )
+ .each( function() {
+ const $thumbItem = $( this );
+ const isActive = $thumbItem.find( 'img.flex-active' ).length > 0;
+
+ $thumbItem
+ .find( '.woocommerce-product-gallery__video-thumbnail-preview' )
+ .css( 'opacity', isActive ? 1 : 0.5 );
+ } );
+ };
+
/**
* Init zoom.
*/
@@ -382,14 +490,7 @@ jQuery( function( $ ) {
this.openPhotoswipe( e );
}
} );
- this.$target.on( 'click', '.woocommerce-product-gallery__image a', function( e ) {
- e.preventDefault();
- });
-
- // If flexslider is disabled, gallery images also need to trigger photoswipe on click.
- if ( ! this.flexslider_enabled ) {
- this.$target.on( 'click', '.woocommerce-product-gallery__image a', this.openPhotoswipe );
- }
+ this.$target.on( 'click', '.woocommerce-product-gallery__image a', this.openPhotoswipe );
} else {
this.$target.on( 'click', '.woocommerce-product-gallery__image a', this.openPhotoswipe );
}
@@ -403,28 +504,47 @@ jQuery( function( $ ) {
};
/**
- * Get product gallery image items.
+ * Get product gallery media items.
*/
ProductGallery.prototype.getGalleryItems = function() {
- var $slides = this.$images,
- items = [];
+ const $slides = this.$images;
+ const items = [];
+ const gallery = this;
if ( $slides.length > 0 ) {
$slides.each( function( i, el ) {
- var img = $( el ).find( 'img' );
-
- if ( img.length ) {
- var large_image_src = img.attr( 'data-large_image' ),
- large_image_w = img.attr( 'data-large_image_width' ),
- large_image_h = img.attr( 'data-large_image_height' ),
- alt = img.attr( 'alt' ),
- item = {
- alt : alt,
- src : large_image_src,
- w : large_image_w,
- h : large_image_h,
- title: img.attr( 'data-caption' ) ? img.attr( 'data-caption' ) : img.attr( 'title' )
+ const media = $( el )
+ .find( 'img[data-large_image], video[data-large_image]' )
+ .first();
+
+ if ( media.length ) {
+ const large_image_src = media.attr( 'data-large_image' );
+ const large_image_w = media.attr( 'data-large_image_width' );
+ const large_image_h = media.attr( 'data-large_image_height' );
+ const alt = media.attr( 'alt' ) || media.attr( 'aria-label' );
+ const title = media.attr( 'data-caption' )
+ ? media.attr( 'data-caption' )
+ : media.attr( 'title' );
+ let item;
+
+ if ( media.is( 'video' ) ) {
+ item = {
+ html: gallery.getPhotoswipeVideoHtml( media ),
+ w: large_image_w,
+ h: large_image_h,
+ title,
+ video: true,
+ };
+ } else {
+ item = {
+ alt,
+ src: large_image_src,
+ w: large_image_w,
+ h: large_image_h,
+ title,
};
+ }
+
items.push( item );
}
} );
@@ -433,6 +553,60 @@ jQuery( function( $ ) {
return items;
};
+ /**
+ * Get PhotoSwipe video slide HTML.
+ */
+ ProductGallery.prototype.getPhotoswipeVideoHtml = function( media ) {
+ const video = media.get( 0 );
+ const src = video.currentSrc || media.attr( 'src' );
+ const $video = $( '<video />', {
+ class: 'woocommerce-product-gallery__photoswipe-video',
+ src,
+ preload: 'metadata',
+ muted: 'muted',
+ loop: 'loop',
+ playsinline: 'playsinline',
+ style: 'display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;',
+ 'aria-label': media.attr( 'aria-label' ) || '',
+ } );
+ const poster = media.attr( 'poster' );
+
+ if ( poster ) {
+ $video.attr( 'poster', poster );
+ }
+
+ return $( '<div />', {
+ class: 'woocommerce-product-gallery__photoswipe-video-wrapper',
+ style: 'display:flex;align-items:center;justify-content:center;width:100%;height:100%;',
+ } )
+ .append( $video )
+ .prop( 'outerHTML' );
+ };
+
+ /**
+ * Play only the current PhotoSwipe video.
+ */
+ ProductGallery.prototype.syncPhotoswipeVideoPlayback = function( photoswipe ) {
+ const activeContainer =
+ photoswipe.currItem && photoswipe.currItem.container;
+
+ $( photoswipe.template )
+ .find( 'video.woocommerce-product-gallery__photoswipe-video' )
+ .each( function() {
+ if ( activeContainer && activeContainer.contains( this ) ) {
+ const playPromise = this.play();
+
+ if ( playPromise && playPromise.catch ) {
+ playPromise.catch( function() {
+ return undefined;
+ } );
+ }
+ } else {
+ this.pause();
+ }
+ } );
+ };
+
/**
* Open photoswipe modal.
*/
@@ -443,8 +617,10 @@ jQuery( function( $ ) {
items = this.getGalleryItems(),
eventTarget = $( e.target ),
currentTarget = e.currentTarget,
+ flexslider = this.flexslider_enabled ? this.$target.data( 'flexslider' ) : false,
self = this,
- clicked;
+ clicked,
+ index;
if ( 0 < eventTarget.closest( '.woocommerce-product-gallery__trigger' ).length ) {
clicked = this.$target.find( '.flex-active-slide' );
@@ -452,8 +628,14 @@ jQuery( function( $ ) {
clicked = eventTarget.closest( '.woocommerce-product-gallery__image' );
}
+ index = $( clicked ).index();
+
+ if ( flexslider && 'number' === typeof flexslider.currentSlide ) {
+ index = flexslider.currentSlide;
+ }
+
var options = $.extend( {
- index: $( clicked ).index(),
+ index: index,
addCaptionHTMLFn: function( item, captionEl ) {
if ( ! item.title ) {
captionEl.children[0].textContent = '';
@@ -470,9 +652,19 @@ jQuery( function( $ ) {
photoswipe.listen( 'afterInit', function() {
self.trapFocusPhotoswipe( true );
+ self.syncPhotoswipeVideoPlayback( photoswipe );
+ });
+
+ photoswipe.listen( 'afterChange', function() {
+ self.syncPhotoswipeVideoPlayback( photoswipe );
});
photoswipe.listen( 'close', function() {
+ $( photoswipe.template )
+ .find( 'video.woocommerce-product-gallery__photoswipe-video' )
+ .each( function() {
+ this.pause();
+ } );
self.trapFocusPhotoswipe( false );
currentTarget.focus();
});
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
index 00bb9177662..dc31966eabd 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -8,6 +8,7 @@
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -188,6 +189,10 @@ class WC_Admin_Duplicate_Product {
// Save parent product.
$duplicate->save();
+ if ( ProductMediaGallery::is_feature_enabled() ) {
+ ProductMediaGallery::copy_stored_video_gallery_items( $product, $duplicate );
+ }
+
/**
* Duplicate children of a variable product.
*
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-images.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-images.php
index 867d1b4d03c..da643ca266c 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-images.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-images.php
@@ -11,6 +11,7 @@
*/
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
@@ -31,34 +32,70 @@ class WC_Meta_Box_Product_Images {
$thepostid = $post->ID;
$product_object = $thepostid ? wc_get_product( $thepostid ) : new WC_Product();
+ $product_object = $product_object instanceof WC_Product ? $product_object : new WC_Product();
wp_nonce_field( 'woocommerce_save_data', 'woocommerce_meta_nonce' );
?>
<div id="product_images_container">
<ul class="product_images">
<?php
- $product_image_gallery = $product_object->get_gallery_image_ids( 'edit' );
+ $product_gallery_videos_enabled = ProductMediaGallery::is_feature_enabled();
+ $product_media_gallery = $product_gallery_videos_enabled
+ ? ProductMediaGallery::get_product_media_gallery_items(
+ $product_object,
+ array(
+ 'context' => 'edit',
+ 'include_product_image' => false,
+ )
+ )
+ : ProductMediaGallery::get_product_image_media_items( $product_object, false, 'edit' );
+ $update_meta = false;
+ $updated_gallery_ids = array();
+ $updated_media_gallery = array();
- $attachments = array_filter( $product_image_gallery );
- $update_meta = false;
- $updated_gallery_ids = array();
-
- if ( ! empty( $attachments ) ) {
+ if ( ! empty( $product_media_gallery ) ) {
// Prime caches to reduce future queries.
- _prime_post_caches( $attachments );
+ ProductMediaGallery::prime_attachment_caches( $product_media_gallery, true );
- foreach ( $attachments as $attachment_id ) {
- $attachment = wp_get_attachment_image( $attachment_id, 'thumbnail' );
+ foreach ( $product_media_gallery as $media_item ) {
+ $attachment_id = $media_item['id'];
+ $media_type = $media_item['media_type'];
+ $poster_id = $media_item['poster_id'] ?? 0;
+ $attachment = 'video' === $media_type
+ ? self::get_video_preview_html( $attachment_id )
+ : wp_get_attachment_image( $attachment_id, 'thumbnail' );
- // if attachment is empty skip.
+ // If attachment is empty skip.
if ( empty( $attachment ) ) {
$update_meta = true;
continue;
}
+
+ if ( 'image' === $media_type ) {
+ $updated_gallery_ids[] = $attachment_id;
+ }
+
+ $updated_media_item = array(
+ 'media_type' => $media_type,
+ 'source_type' => 'attachment',
+ 'id' => $attachment_id,
+ );
+
+ if ( 'video' === $media_type && $poster_id ) {
+ $updated_media_item['poster_id'] = $poster_id;
+ }
+
+ $updated_media_gallery[] = $updated_media_item;
?>
- <li class="image" data-attachment_id="<?php echo esc_attr( $attachment_id ); ?>">
+ <li
+ class="image <?php echo 'video' === $media_type ? 'video' : ''; ?>"
+ data-attachment_id="<?php echo esc_attr( (string) $attachment_id ); ?>"
+ data-media_type="<?php echo esc_attr( (string) $media_type ); ?>"
+ data-source_type="attachment"
+ data-poster_id="<?php echo esc_attr( (string) $poster_id ); ?>"
+ >
<?php echo $attachment; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<ul class="actions">
- <li><a href="#" class="delete tips" data-tip="<?php esc_attr_e( 'Delete image', 'woocommerce' ); ?>"><?php esc_html_e( 'Delete', 'woocommerce' ); ?></a></li>
+ <li><a href="#" class="delete tips" data-tip="<?php esc_attr_e( 'Delete media', 'woocommerce' ); ?>"><?php esc_html_e( 'Delete', 'woocommerce' ); ?></a></li>
</ul>
<?php
// Allow for extra info to be exposed or extra action to be executed for this attachment.
@@ -66,12 +103,9 @@ class WC_Meta_Box_Product_Images {
?>
</li>
<?php
-
- // rebuild ids to be saved.
- $updated_gallery_ids[] = $attachment_id;
}
- // need to update product meta to set new gallery ids
+ // Need to update product meta to set new gallery ids.
if ( $update_meta ) {
update_post_meta( $post->ID, '_product_image_gallery', implode( ',', $updated_gallery_ids ) );
}
@@ -80,10 +114,36 @@ class WC_Meta_Box_Product_Images {
</ul>
<input type="hidden" id="product_image_gallery" name="product_image_gallery" value="<?php echo esc_attr( implode( ',', $updated_gallery_ids ) ); ?>" />
+ <?php if ( $product_gallery_videos_enabled ) : ?>
+ <input
+ type="hidden"
+ id="product_media_gallery"
+ name="product_media_gallery"
+ value="<?php echo esc_attr( self::get_media_gallery_json( $updated_media_gallery ) ); ?>"
+ />
+ <?php endif; ?>
</div>
<p class="add_product_images hide-if-no-js">
- <a href="#" data-choose="<?php esc_attr_e( 'Add images to product gallery', 'woocommerce' ); ?>" data-update="<?php esc_attr_e( 'Add to gallery', 'woocommerce' ); ?>" data-delete="<?php esc_attr_e( 'Delete image', 'woocommerce' ); ?>" data-text="<?php esc_attr_e( 'Delete', 'woocommerce' ); ?>"><?php esc_html_e( 'Add product gallery images', 'woocommerce' ); ?></a>
+ <?php if ( $product_gallery_videos_enabled ) : ?>
+ <a
+ href="#"
+ data-choose="<?php esc_attr_e( 'Add media to product gallery', 'woocommerce' ); ?>"
+ data-update="<?php esc_attr_e( 'Add to gallery', 'woocommerce' ); ?>"
+ data-delete="<?php esc_attr_e( 'Delete media', 'woocommerce' ); ?>"
+ data-text="<?php esc_attr_e( 'Delete', 'woocommerce' ); ?>"
+ data-allow_videos="yes"
+ ><?php esc_html_e( 'Add media to product gallery', 'woocommerce' ); ?></a>
+ <?php else : ?>
+ <a
+ href="#"
+ data-choose="<?php esc_attr_e( 'Add images to product gallery', 'woocommerce' ); ?>"
+ data-update="<?php esc_attr_e( 'Add to gallery', 'woocommerce' ); ?>"
+ data-delete="<?php esc_attr_e( 'Delete image', 'woocommerce' ); ?>"
+ data-text="<?php esc_attr_e( 'Delete', 'woocommerce' ); ?>"
+ data-allow_videos="no"
+ ><?php esc_html_e( 'Add product gallery images', 'woocommerce' ); ?></a>
+ <?php endif; ?>
</p>
<?php
}
@@ -95,12 +155,117 @@ class WC_Meta_Box_Product_Images {
* @param WP_Post $post
*/
public static function save( $post_id, $post ) {
- $product_type = empty( $_POST['product-type'] ) ? WC_Product_Factory::get_product_type( $post_id ) : sanitize_title( stripslashes( $_POST['product-type'] ) );
- $classname = WC_Product_Factory::get_product_classname( $post_id, $product_type ? $product_type : ProductType::SIMPLE );
- $product = new $classname( $post_id );
+ $product_type = empty( $_POST['product-type'] ) ? WC_Product_Factory::get_product_type( $post_id ) : sanitize_title( wp_unslash( $_POST['product-type'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $classname = WC_Product_Factory::get_product_classname( $post_id, $product_type ? $product_type : ProductType::SIMPLE );
+ $product = new $classname( $post_id );
+
+ if ( ! $product instanceof WC_Product ) {
+ return;
+ }
+
$attachment_ids = isset( $_POST['product_image_gallery'] ) ? array_filter( explode( ',', wc_clean( $_POST['product_image_gallery'] ) ) ) : array();
+ $videos_enabled = ProductMediaGallery::is_feature_enabled();
+
+ if ( ! $videos_enabled ) {
+ $product->set_gallery_image_ids( $attachment_ids );
+ $product->save();
+ return;
+ }
+
+ $media_gallery = array();
+
+ if ( isset( $_POST['product_media_gallery'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $posted_media_gallery = wc_clean( wp_unslash( $_POST['product_media_gallery'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $media_gallery = ProductMediaGallery::normalize_media_gallery_items(
+ self::decode_media_gallery_json( $posted_media_gallery )
+ );
+ $attachment_ids = ProductMediaGallery::get_image_ids( $media_gallery );
+ }
$product->set_gallery_image_ids( $attachment_ids );
$product->save();
+
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ ProductMediaGallery::get_positioned_video_gallery_items_from_media_gallery( $media_gallery )
+ );
+ }
+
+ /**
+ * Get HTML preview for a video attachment.
+ *
+ * @param int $attachment_id Attachment ID.
+ * @return string
+ */
+ private static function get_video_preview_html( $attachment_id ) {
+ $poster_id = get_post_thumbnail_id( $attachment_id );
+ $preview = '';
+
+ if ( $poster_id ) {
+ $preview = wp_get_attachment_image(
+ $poster_id,
+ 'thumbnail',
+ false,
+ array(
+ 'class' => 'woocommerce-product-gallery__video-preview',
+ )
+ );
+ }
+
+ if ( $preview ) {
+ return $preview;
+ }
+
+ $video_src = wp_get_attachment_url( $attachment_id );
+
+ if ( $video_src ) {
+ return sprintf(
+ '<video class="woocommerce-product-gallery__video-preview" src="%1$s" preload="metadata" muted="muted" aria-hidden="true"></video>',
+ esc_url( $video_src )
+ );
+ }
+
+ return wp_get_attachment_image(
+ $attachment_id,
+ 'thumbnail',
+ true,
+ array(
+ 'class' => 'woocommerce-product-gallery__video-preview',
+ )
+ );
+ }
+
+ /**
+ * Encode media gallery items for the hidden form field.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @return string
+ */
+ private static function get_media_gallery_json( $media_gallery ) {
+ $json = wp_json_encode( $media_gallery );
+
+ return false === $json ? '[]' : $json;
+ }
+
+ /**
+ * Decode the posted media gallery hidden field.
+ *
+ * @param mixed $media_gallery_json JSON-encoded media gallery field value.
+ * @return array
+ */
+ private static function decode_media_gallery_json( $media_gallery_json ) {
+ if ( ! is_string( $media_gallery_json ) || '' === $media_gallery_json ) {
+ return array();
+ }
+
+ $decoded = json_decode( $media_gallery_json, true );
+
+ if ( ! is_array( $decoded ) ) {
+ return array();
+ }
+
+ $decoded = wc_clean( $decoded );
+
+ return is_array( $decoded ) ? $decoded : array();
}
}
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index de9b861600d..2961c4632a1 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -72,6 +72,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
'_thumbnail_id',
'_file_paths',
'_product_image_gallery',
+ '_wc_video_gallery',
'_product_version',
'_wp_old_slug',
'_edit_last',
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index aec062ee748..e3f39cdf942 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Enums\TaxDisplayMode;
use Automattic\WooCommerce\Internal\Caches\ProductTransientsDeferrer;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -923,6 +924,18 @@ add_filter( 'wp_get_attachment_image_attributes', 'wc_get_attachment_image_attri
* @return array
*/
function wc_prepare_attachment_for_js( $response ) {
+ if (
+ ProductMediaGallery::is_feature_enabled() &&
+ isset( $response['id'], $response['type'] ) &&
+ 'video' === $response['type']
+ ) {
+ $poster_id = absint( get_post_thumbnail_id( $response['id'] ) );
+
+ if ( $poster_id ) {
+ $response['poster_id'] = $poster_id;
+ }
+ }
+
/*
* If the user can manage woocommerce, allow them to
* see the image content.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 3b414bb09bf..5233cf3d74f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -5712,24 +5712,6 @@ parameters:
count: 1
path: includes/admin/meta-boxes/class-wc-meta-box-product-data.php
- -
- message: '#^Call to an undefined method object\:\:save\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/admin/meta-boxes/class-wc-meta-box-product-images.php
-
- -
- message: '#^Call to an undefined method object\:\:set_gallery_image_ids\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/admin/meta-boxes/class-wc-meta-box-product-images.php
-
- -
- message: '#^Cannot call method get_gallery_image_ids\(\) on WC_Product\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/admin/meta-boxes/class-wc-meta-box-product-images.php
-
-
message: '#^Method WC_Meta_Box_Product_Images\:\:output\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -52821,12 +52803,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductGalleryLargeImage.php
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductGalleryLargeImage\:\:get_main_images_html\(\) should return array but returns string\.$#'
- identifier: return.type
- count: 1
- path: src/Blocks/BlockTypes/ProductGalleryLargeImage.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductGalleryLargeImage\:\:initialize\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -52845,18 +52821,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductGalleryLargeImage.php
- -
- message: '#^Parameter \#1 \$query of method WP_HTML_Tag_Processor\:\:next_tag\(\) expects array\{tag_name\?\: string\|null, match_offset\?\: int\|null, class_name\?\: string\|null, tag_closers\?\: string\|null\}\|null, ''a'' given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/BlockTypes/ProductGalleryLargeImage.php
-
- -
- message: '#^Parameter \#1 \$query of method WP_HTML_Tag_Processor\:\:next_tag\(\) expects array\{tag_name\?\: string\|null, match_offset\?\: int\|null, class_name\?\: string\|null, tag_closers\?\: string\|null\}\|null, ''img'' given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/BlockTypes/ProductGalleryLargeImage.php
-
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index 7d6a33846a3..c8711f6233e 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\ProductGalleryUtils;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
/**
* ProductGallery class.
@@ -32,19 +33,10 @@ class ProductGallery extends AbstractBlock {
/**
* Return the dialog content.
*
- * @param array $images An array of all images of the product.
+ * @param array $media_items An array of all media items of the product.
* @return string
*/
- protected function render_dialog( $images ) {
- $images_html = '';
- foreach ( $images as $image ) {
- $id = esc_attr( $image['id'] );
- $src = esc_url( $image['src'] );
- $srcset = esc_attr( $image['srcset'] );
- $sizes = esc_attr( $image['sizes'] );
- $alt = esc_attr( $image['alt'] );
- $images_html .= "<img data-image-id='{$id}' data-wp-watch='callbacks.toggleImageVisibility' src='{$src}' srcset='{$srcset}' sizes='{$sizes}' decoding='async' alt='{$alt}' />";
- }
+ protected function render_dialog( $media_items ) {
ob_start();
?>
<dialog
@@ -65,14 +57,38 @@ class ProductGallery extends AbstractBlock {
</button>
</div>
<div class="wc-block-product-gallery-dialog__content">
- <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute values are escaped above when building $images_html. ?>
- <?php echo $images_html; ?>
+ <?php foreach ( $media_items as $index => $media ) : ?>
+ <?php if ( 'video' === ( $media['media_type'] ?? '' ) ) : ?>
+ <?php echo $this->get_dialog_video_html( $media ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php else : ?>
+ <img
+ data-image-id="<?php echo esc_attr( $media['id'] ); ?>"
+ data-wp-watch="callbacks.toggleImageVisibility"
+ src="<?php echo esc_url( $media['src'] ); ?>"
+ srcset="<?php echo esc_attr( $media['srcset'] ); ?>"
+ sizes="<?php echo esc_attr( $media['sizes'] ); ?>"
+ decoding="async"
+ alt="<?php echo esc_attr( $media['alt'] ); ?>" />
+ <?php endif; ?>
+ <?php endforeach; ?>
</div>
</dialog>
<?php
return ob_get_clean();
}
+ /**
+ * Get video HTML for the product gallery dialog.
+ *
+ * @param array $media Video media data.
+ * @return string
+ */
+ private function get_dialog_video_html( $media ) {
+ return ProductGalleryUtils::get_video_html(
+ ProductGalleryUtils::get_video_attributes( $media, 'dialog' )
+ );
+ }
+
/**
* Inject dialog into the product gallery HTML.
*
@@ -112,16 +128,18 @@ class ProductGallery extends AbstractBlock {
return '';
}
- $image_ids = ProductGalleryUtils::get_all_image_ids( $product );
- $default_image_ids = array_map( 'intval', ProductGalleryUtils::get_product_gallery_image_ids( $product ) );
+ $media_items = ProductGalleryUtils::get_all_media_items( $product );
+ $default_media_ids = ProductGalleryUtils::get_media_ids(
+ ProductMediaGallery::get_product_media_gallery_items_for_display( $product )
+ );
- $number_of_images = count( $default_image_ids );
+ $number_of_media = count( $default_media_ids );
$classname = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );
- $initial_image_id = $number_of_images > 0 ? $default_image_ids[0] : -1;
- $classname_single_image = $number_of_images < 2 ? 'is-single-product-gallery-image' : '';
+ $initial_media_id = $number_of_media > 0 ? $default_media_ids[0] : -1;
+ $classname_single_image = $number_of_media < 2 ? 'is-single-product-gallery-image' : '';
$product_id = strval( $product->get_id() );
- $fullsize_image_data = ProductGalleryUtils::get_image_src_data( $image_ids, 'full', $product->get_title() );
- $gallery_with_dialog = $this->inject_dialog( $content, $this->render_dialog( $fullsize_image_data ) );
+ $fullsize_media_data = ProductGalleryUtils::get_media_src_data( $media_items, 'full', $product->get_title() );
+ $gallery_with_dialog = $this->inject_dialog( $content, $this->render_dialog( $fullsize_media_data ) );
$p = new \WP_HTML_Tag_Processor( $gallery_with_dialog );
$html = $gallery_with_dialog;
@@ -131,13 +149,13 @@ class ProductGallery extends AbstractBlock {
'data-wp-context',
wp_json_encode(
array(
- 'imageData' => $default_image_ids,
+ 'imageData' => $default_media_ids,
'isDialogOpen' => false,
'isDragging' => false,
'touchStartX' => 0,
'touchCurrentX' => 0,
'productId' => $product_id,
- 'selectedImageId' => $initial_image_id,
+ 'selectedImageId' => $initial_media_id,
'thumbnailsOverflow' => [
'top' => false,
'bottom' => false,
@@ -145,9 +163,9 @@ class ProductGallery extends AbstractBlock {
'right' => false,
],
// Next/Previous Buttons block context.
- 'hideNextPreviousButtons' => $number_of_images <= 1,
+ 'hideNextPreviousButtons' => $number_of_media <= 1,
'isDisabledPrevious' => true,
- 'isDisabledNext' => $number_of_images <= 1,
+ 'isDisabledNext' => $number_of_media <= 1,
'ariaLabelPrevious' => __( 'Previous image', 'woocommerce' ),
'ariaLabelNext' => __( 'Next image', 'woocommerce' ),
),
@@ -164,8 +182,8 @@ class ProductGallery extends AbstractBlock {
array(
'products' => array(
$product->get_id() => array(
- 'image_id' => (int) $product->get_image_id(),
- 'image_ids' => $default_image_ids,
+ 'image_id' => $initial_media_id,
+ 'image_ids' => $default_media_ids,
'variations' => $formatted_variations_data,
),
),
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
index e786048096d..7b7489b5b3f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
@@ -85,13 +85,13 @@ class ProductGalleryLargeImage extends AbstractBlock {
foreach ( $block->inner_blocks as $inner_block ) {
if ( 'woocommerce/product-image' === $inner_block->name ) {
- // Product Image requires special handling because we need to render it once for each image.
- $images_html .= $this->get_main_images_html( $block->context, $product, $inner_block );
+ // Product Image requires special handling because we need to render it once for each media item.
+ $images_html .= $this->get_main_media_html( $block->context, $product, $inner_block );
} else {
- // For Next/Previous Buttons block, check if we have more than one image, otherwise don't render it.
+ // For Next/Previous Buttons block, check if we have more than one media item, otherwise don't render it.
if ( 'woocommerce/product-gallery-large-image-next-previous' === $inner_block->name ) {
- $product_gallery_image_count = ProductGalleryUtils::get_product_gallery_image_count( $product );
- if ( $product_gallery_image_count <= 1 ) {
+ $product_gallery_media_count = ProductGalleryUtils::get_product_gallery_media_count( $product );
+ if ( $product_gallery_media_count <= 1 ) {
continue;
}
}
@@ -114,7 +114,6 @@ class ProductGalleryLargeImage extends AbstractBlock {
ob_start();
?>
<div class="wc-block-product-gallery-large-image wp-block-woocommerce-product-gallery-large-image">
- <?php // No need to use wp_kses here because the image HTML is built internally. ?>
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo $images_html; ?>
<div class="wc-block-product-gallery-large-image__inner-blocks">
@@ -137,22 +136,10 @@ class ProductGalleryLargeImage extends AbstractBlock {
* @return string
*/
private function update_single_image( $image_html, $context, $index ) {
- $p = new \WP_HTML_Tag_Processor( $image_html );
-
- if ( $p->next_tag( 'a' ) ) {
- $p->remove_attribute( 'onclick' );
- $p->remove_attribute( 'style' );
- $p->set_attribute( 'tabindex', '-1' );
- } else {
- /**
- * If we can't find and <a> tag, we're at the end of the document.
- * We need to reinitialize the processor instance to search for <img> tag.
- */
- $p = new \WP_HTML_Tag_Processor( $image_html );
- }
+ $p = $this->get_product_image_processor( $image_html );
// Bail out early if we don't find any image.
- if ( ! $p->next_tag( 'img' ) ) {
+ if ( ! $p->next_tag( array( 'tag_name' => 'img' ) ) ) {
return $image_html;
}
@@ -192,15 +179,15 @@ class ProductGalleryLargeImage extends AbstractBlock {
}
/**
- * Get the main images html code. The first element of the array contains the HTML of the first image that is visible, the second element contains the HTML of the other images that are hidden.
+ * Get the main media HTML.
*
* @param array $context The block context.
* @param \WC_Product $product The product object.
* @param WP_Block $inner_block The inner block object.
- * @return array
+ * @return string
*/
- private function get_main_images_html( $context, $product, $inner_block ) {
- $image_data = ProductGalleryUtils::get_product_gallery_image_data( $product, 'woocommerce_single' );
+ private function get_main_media_html( $context, $product, $inner_block ) {
+ $media_data = ProductGalleryUtils::get_product_gallery_media_data( $product, 'woocommerce_single' );
ob_start();
?>
@@ -212,19 +199,25 @@ class ProductGalleryLargeImage extends AbstractBlock {
tabindex="0"
aria-roledescription="carousel"
>
- <?php foreach ( $image_data as $index => $image ) : ?>
+ <?php foreach ( $media_data as $index => $media ) : ?>
<li
class="wc-block-product-gallery-large-image__wrapper"
>
<?php
- $image_html = (
- new WP_Block(
- $inner_block->parsed_block,
- array_merge( $context, array( 'imageId' => $image['id'] ) )
- )
- )->render( array( 'dynamic' => true ) );
-
- echo $this->update_single_image( $image_html, $context, $index ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ if ( 'video' === ( $media['media_type'] ?? '' ) ) {
+ echo $this->get_video_html( $media, $context, $inner_block ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ continue;
+ }
+
+ $image_html = (
+ new WP_Block(
+ $inner_block->parsed_block,
+ array_merge( $context, array( 'imageId' => $media['id'] ) )
+ )
+ )->render( array( 'dynamic' => true ) );
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $this->update_single_image( $image_html, $context, $index );
?>
</li>
<?php endforeach; ?>
@@ -235,6 +228,119 @@ class ProductGalleryLargeImage extends AbstractBlock {
return wp_interactivity_process_directives( $template );
}
+ /**
+ * Get a Product Image processor prepared for gallery updates.
+ *
+ * @param string $image_html The image html.
+ * @return \WP_HTML_Tag_Processor
+ */
+ private function get_product_image_processor( $image_html ) {
+ $p = new \WP_HTML_Tag_Processor( $image_html );
+
+ if ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) {
+ $p->remove_attribute( 'onclick' );
+ $p->remove_attribute( 'style' );
+ $p->set_attribute( 'tabindex', '-1' );
+ } else {
+ $p = new \WP_HTML_Tag_Processor( $image_html );
+ }
+
+ return $p;
+ }
+
+ /**
+ * Get video HTML for the product gallery large image area.
+ *
+ * @param array $media Video media data.
+ * @param array $context The block context.
+ * @param WP_Block $inner_block The inner block object.
+ * @return string
+ */
+ private function get_video_html( $media, $context, $inner_block ) {
+ $image_html = (
+ new WP_Block(
+ $inner_block->parsed_block,
+ $context
+ )
+ )->render( array( 'dynamic' => true ) );
+
+ return $this->replace_product_image_with_video( $image_html, $media, $context );
+ }
+
+ /**
+ * Replace Product Image markup with product gallery video markup.
+ *
+ * @param string $image_html Product Image HTML.
+ * @param array $media Video media data.
+ * @param array $context The block context.
+ * @return string
+ */
+ private function replace_product_image_with_video( $image_html, $media, $context ) {
+ $p = $this->get_product_image_processor( $image_html );
+
+ if ( ! $p->next_tag( array( 'tag_name' => 'img' ) ) ) {
+ return $image_html;
+ }
+
+ $attrs = ProductGalleryUtils::get_video_attributes( $media, 'gallery' );
+
+ if ( empty( $attrs ) ) {
+ return $image_html;
+ }
+
+ $image_style = $p->get_attribute( 'style' );
+ $video_classes = 'wc-block-woocommerce-product-gallery-large-image__image ' .
+ 'wc-block-woocommerce-product-gallery-large-image__video';
+
+ if ( ! empty( $context['fullScreenOnClick'] ) ) {
+ $video_classes .= ' wc-block-woocommerce-product-gallery-large-image__image--full-screen-on-click';
+ $attrs['data-wp-on--click'] = 'actions.openDialog';
+ }
+
+ $attrs = array_merge(
+ $attrs,
+ array(
+ 'class' => $video_classes,
+ 'data-wp-on--touchend' => 'actions.onTouchEnd',
+ 'data-wp-on--touchmove' => 'actions.onTouchMove',
+ 'data-wp-on--touchstart' => 'actions.onTouchStart',
+ 'draggable' => 'false',
+ 'tabindex' => '-1',
+ )
+ );
+
+ if ( is_string( $image_style ) && '' !== $image_style ) {
+ $attrs['style'] = $image_style;
+ }
+
+ $placeholder_attribute = 'data-wc-product-gallery-video-placeholder';
+
+ if ( ! $p->set_attribute( $placeholder_attribute, '1' ) ) {
+ return $image_html;
+ }
+
+ $updated_html = $p->get_updated_html();
+ $video_html = ProductGalleryUtils::get_video_html( $attrs );
+ $pattern = sprintf(
+ '/<img\b(?=[^>]*\s%s(?:\s*=\s*(?:"1"|\'1\'|1))?)[^>]*>/i',
+ preg_quote( $placeholder_attribute, '/' )
+ );
+ $replacement_count = 0;
+ $video_markup = preg_replace_callback(
+ $pattern,
+ static function () use ( $video_html ) {
+ return $video_html;
+ },
+ $updated_html,
+ 1,
+ $replacement_count
+ );
+
+ return is_string( $video_markup ) && $replacement_count
+ ? $video_markup
+ : $image_html;
+ }
+
/**
* Disable the editor style handle for this block type.
*
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
index d7fa9a8913d..f3bfc3dcd41 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
@@ -57,11 +57,11 @@ class ProductGalleryThumbnails extends AbstractBlock {
// We crop the images to square only if the aspect ratio is 1:1.
// Otherwise, we show the uncropped and use object-fit to crop them.
- $image_size = '1' === $attributes['aspectRatio'] ? 'woocommerce_thumbnail' : 'woocommerce_single';
- $product_gallery_images = ProductGalleryUtils::get_product_gallery_image_data( $product, $image_size );
+ $image_size = '1' === $attributes['aspectRatio'] ? 'woocommerce_thumbnail' : 'woocommerce_single';
+ $product_gallery_media = ProductGalleryUtils::get_product_gallery_media_data( $product, $image_size );
- // Don't show the thumbnails block if there is only one image.
- if ( count( $product_gallery_images ) <= 1 ) {
+ // Don't show the thumbnails block if there is only one media item.
+ if ( count( $product_gallery_media ) <= 1 ) {
return '';
}
@@ -87,24 +87,51 @@ class ProductGalleryThumbnails extends AbstractBlock {
data-wp-init--hide-ghost-overflow="callbacks.hideGhostOverflow"
data-wp-on--scroll="actions.onScroll"
role="listbox">
- <?php foreach ( $product_gallery_images as $index => $image ) : ?>
- <div class="wc-block-product-gallery-thumbnails__thumbnail">
- <img
- class="<?php echo 0 === $index ? esc_attr( $img_class . ' wc-block-product-gallery-thumbnails__thumbnail__image--is-active' ) : esc_attr( $img_class ); ?>"
- data-image-id="<?php echo esc_attr( $image['id'] ); ?>"
- src="<?php echo esc_attr( $image['src'] ); ?>"
- srcset="<?php echo esc_attr( $image['srcset'] ); ?>"
- sizes="<?php echo esc_attr( $image['sizes'] ); ?>"
- alt="<?php echo esc_attr( $image['alt'] ); ?>"
- data-wp-on--click="actions.selectCurrentImage"
- data-wp-on--keydown="actions.onThumbnailsArrowsKeyDown"
- data-wp-watch="callbacks.syncThumbnailState"
- decoding="async"
- tabindex="<?php echo 0 === $index ? '0' : '-1'; ?>"
- draggable="false"
- loading="lazy"
- role="option"
- style="aspect-ratio: <?php echo esc_attr( $attributes['aspectRatio'] ); ?>" />
+ <?php foreach ( $product_gallery_media as $index => $media ) : ?>
+ <?php
+ $media_type = isset( $media['media_type'] ) && is_string( $media['media_type'] ) ? sanitize_key( $media['media_type'] ) : 'image';
+ $thumbnail_classes = 'wc-block-product-gallery-thumbnails__thumbnail';
+ $thumbnail_image_class = 0 === $index ? $img_class . ' wc-block-product-gallery-thumbnails__thumbnail__image--is-active' : $img_class;
+ $thumbnail_aspect_ratio = $attributes['aspectRatio'] ?? '1';
+ $thumbnail_aspect_ratio = is_string( $thumbnail_aspect_ratio ) ? $thumbnail_aspect_ratio : '1';
+
+ if ( 'video' === $media_type ) {
+ $thumbnail_classes .= ' wc-block-product-gallery-thumbnails__thumbnail--video';
+ }
+
+ $thumbnail_attributes = array(
+ 'class' => $thumbnail_image_class,
+ 'data-image-id' => isset( $media['id'] ) ? absint( $media['id'] ) : 0,
+ 'data-media-type' => $media_type,
+ 'data-wp-on--click' => 'actions.selectCurrentImage',
+ 'data-wp-on--keydown' => 'actions.onThumbnailsArrowsKeyDown',
+ 'data-wp-watch' => 'callbacks.syncThumbnailState',
+ 'draggable' => 'false',
+ 'role' => 'option',
+ 'style' => 'aspect-ratio: ' . $thumbnail_aspect_ratio,
+ 'tabindex' => 0 === $index ? '0' : '-1',
+ );
+ $show_video_preview = 'video' === $media_type && empty( $media['poster_id'] ) && ! empty( $media['video_src'] );
+ ?>
+ <div class="<?php echo esc_attr( $thumbnail_classes ); ?>">
+ <?php if ( $show_video_preview ) : ?>
+ <video
+ <?php echo wc_implode_html_attributes( $thumbnail_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ src="<?php echo esc_url( $media['video_src'] ); ?>"
+ aria-label="<?php echo esc_attr( $media['alt'] ); ?>"
+ preload="metadata"
+ muted="muted"
+ playsinline="playsinline"></video>
+ <?php else : ?>
+ <img
+ <?php echo wc_implode_html_attributes( $thumbnail_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ src="<?php echo esc_attr( $media['src'] ); ?>"
+ srcset="<?php echo esc_attr( $media['srcset'] ); ?>"
+ sizes="<?php echo esc_attr( $media['sizes'] ); ?>"
+ alt="<?php echo esc_attr( $media['alt'] ); ?>"
+ decoding="async"
+ loading="lazy" />
+ <?php endif; ?>
</div>
<?php endforeach; ?>
</div>
diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
index bd8c7b57695..8bab9ada40e 100644
--- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
@@ -1,6 +1,7 @@
<?php
namespace Automattic\WooCommerce\Blocks\Utils;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;
/**
@@ -44,14 +45,82 @@ class ProductGalleryUtils {
}
/**
- * Get the product gallery image count.
+ * Get all media items for the product gallery.
*
- * @param \WC_Product $product The product object to retrieve the gallery images for.
- * @return int The number of images in the product gallery.
+ * @param \WC_Product $product The product object.
+ * @return array[] An array of media gallery items.
*/
- public static function get_product_gallery_image_count( $product ) {
- $all_image_ids = self::get_all_image_ids( $product );
- return count( $all_image_ids );
+ public static function get_all_media_items( $product ) {
+ if ( ! $product instanceof \WC_Product ) {
+ wc_doing_it_wrong( __FUNCTION__, __( 'Invalid product object.', 'woocommerce' ), '10.9.0' );
+ return array();
+ }
+
+ $product_media_items = ProductMediaGallery::get_product_media_gallery_items_for_display( $product );
+ $product_variation_image_ids = self::get_product_variation_image_ids( $product );
+ $is_placeholder_only = 1 === count( $product_media_items ) && 0 === (int) ( $product_media_items[0]['id'] ?? 0 );
+
+ // If the media gallery only returned the placeholder, fall back to gallery images.
+ if ( $is_placeholder_only && ! empty( $product->get_gallery_image_ids() ) ) {
+ $product_media_items = self::get_product_gallery_image_items( $product );
+ }
+
+ $existing_ids = array_map( 'strval', self::get_media_ids( $product_media_items ) );
+
+ foreach ( $product_variation_image_ids as $variation_image_id ) {
+ if ( in_array( strval( $variation_image_id ), $existing_ids, true ) ) {
+ continue;
+ }
+
+ $product_media_items[] = array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => absint( $variation_image_id ),
+ );
+ $existing_ids[] = strval( $variation_image_id );
+ }
+
+ return array_values( $product_media_items );
+ }
+
+ /**
+ * Get product gallery media IDs.
+ *
+ * @param array[] $media_items Product gallery media items.
+ * @return int[] Media IDs used by the product gallery frontend store.
+ */
+ public static function get_media_ids( $media_items ) {
+ return array_values(
+ array_map(
+ static function ( $media_item ) {
+ return isset( $media_item['id'] ) ? (int) $media_item['id'] : 0;
+ },
+ $media_items
+ )
+ );
+ }
+
+ /**
+ * Get the product gallery media data.
+ *
+ * @param \WC_Product $product The product object to retrieve the gallery media for.
+ * @param string $size The size of the poster/image to retrieve.
+ * @return array[] An array of media data for the product gallery.
+ */
+ public static function get_product_gallery_media_data( $product, $size ) {
+ $media_items = self::get_all_media_items( $product );
+ return self::get_media_src_data( $media_items, $size, $product->get_title() );
+ }
+
+ /**
+ * Get the product gallery media count.
+ *
+ * @param \WC_Product $product The product object to retrieve the gallery media for.
+ * @return int The number of media items in the product gallery.
+ */
+ public static function get_product_gallery_media_count( $product ) {
+ $media_items = self::get_all_media_items( $product );
+ return count( $media_items );
}
/**
@@ -66,41 +135,211 @@ class ProductGalleryUtils {
$image_src_data = array();
foreach ( $image_ids as $index => $image_id ) {
- if ( 0 === $image_id ) {
- // Handle placeholder image.
- $image_src_data[] = array(
- 'id' => 0,
- 'src' => wc_placeholder_img_src(),
- 'srcset' => '',
- 'sizes' => '',
- 'alt' => '',
- );
+ $image_src_data[] = self::get_image_data( $image_id, $size, $product_title, $index );
+ }
+
+ return $image_src_data;
+ }
+
+ /**
+ * Get media source data.
+ *
+ * @param array[] $media_items Product gallery media items.
+ * @param string $size The size of the poster/image to retrieve.
+ * @param string $product_title The title of the product used for alt fallback.
+ * @return array[] An array of media source data.
+ */
+ public static function get_media_src_data( $media_items, $size, $product_title = '' ) {
+ $media_src_data = array();
+
+ foreach ( $media_items as $index => $media_item ) {
+ if ( 'video' === ( $media_item['media_type'] ?? '' ) ) {
+ $media_src_data[] = self::get_video_data( $media_item, $size, $product_title, $index );
continue;
}
- // Get the image source.
- $full_src = wp_get_attachment_image_src( $image_id, $size );
-
- // Get srcset and sizes.
- $srcset = wp_get_attachment_image_srcset( $image_id, $size );
- $sizes = wp_get_attachment_image_sizes( $image_id, $size );
- $alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
-
- $image_src_data[] = array(
- 'id' => $image_id,
- 'src' => $full_src ? $full_src[0] : '',
- 'srcset' => $srcset ? $srcset : '',
- 'sizes' => $sizes ? $sizes : '',
- 'alt' => $alt ? $alt : sprintf(
- /* translators: 1: Product title 2: Image number */
- __( '%1$s - Image %2$d', 'woocommerce' ),
- $product_title,
- $index + 1
- ),
+ $media_src_data[] = array_merge(
+ self::get_image_data( $media_item['id'] ?? 0, $size, $product_title, $index ),
+ array( 'media_type' => 'image' )
);
}
- return $image_src_data;
+ return $media_src_data;
+ }
+
+ /**
+ * Get base attributes for product gallery videos.
+ *
+ * @param array $media Video media data.
+ * @param string $video_location Video location in the product gallery.
+ * @return array Video attributes.
+ */
+ public static function get_video_attributes( $media, $video_location ) {
+ if ( empty( $media['video_src'] ) ) {
+ return array();
+ }
+
+ $video_location = in_array( $video_location, array( 'dialog', 'gallery' ), true ) ? $video_location : 'gallery';
+ $attrs = array(
+ 'aria-label' => $media['alt'] ?? '',
+ 'autoplay' => 'autoplay',
+ 'data-image-id' => $media['id'] ?? '',
+ 'data-wp-context' => sprintf( '{"videoLocation":"%s"}', $video_location ),
+ 'data-wp-watch' => 'callbacks.syncVideoPlayback',
+ 'loop' => 'loop',
+ 'muted' => 'muted',
+ 'playsinline' => 'playsinline',
+ 'preload' => 'metadata',
+ 'src' => $media['video_src'],
+ );
+
+ if ( 'dialog' === $video_location ) {
+ unset( $attrs['autoplay'] );
+ $attrs['data-wp-init--dialog-video-playback'] = 'callbacks.initDialogVideoPlayback';
+ }
+
+ if ( ! empty( $media['poster_id'] ) && ! empty( $media['poster_src'] ) ) {
+ $attrs['poster'] = $media['poster_src'];
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * Get product gallery video HTML from attributes.
+ *
+ * @param array $attributes Video attributes.
+ * @return string Video HTML.
+ */
+ public static function get_video_html( $attributes ) {
+ if ( empty( $attributes ) ) {
+ return '';
+ }
+
+ return '<video ' . wc_implode_html_attributes(
+ array_filter(
+ $attributes,
+ static function ( $value ) {
+ return '' !== $value;
+ }
+ )
+ ) . '></video>';
+ }
+
+ /**
+ * Get source data for one image.
+ *
+ * @param int|string $image_id The image ID.
+ * @param string $size The size of the image to retrieve.
+ * @param string $product_title The title of the product used for alt fallback.
+ * @param int $index The image index.
+ * @return array Image source data.
+ */
+ private static function get_image_data( $image_id, $size, $product_title, $index ) {
+ $image_id = (int) $image_id;
+
+ if ( 0 === $image_id ) {
+ return array(
+ 'id' => 0,
+ 'media_type' => 'image',
+ 'src' => wc_placeholder_img_src(),
+ 'srcset' => '',
+ 'sizes' => '',
+ 'alt' => '',
+ );
+ }
+
+ $full_src = wp_get_attachment_image_src( $image_id, $size );
+ $srcset = wp_get_attachment_image_srcset( $image_id, $size );
+ $sizes = wp_get_attachment_image_sizes( $image_id, $size );
+ $alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
+
+ return array(
+ 'id' => $image_id,
+ 'media_type' => 'image',
+ 'src' => $full_src ? $full_src[0] : '',
+ 'srcset' => $srcset ? $srcset : '',
+ 'sizes' => $sizes ? $sizes : '',
+ 'alt' => $alt ? $alt : sprintf(
+ /* translators: 1: Product title 2: Image number */
+ __( '%1$s - Image %2$d', 'woocommerce' ),
+ $product_title,
+ $index + 1
+ ),
+ );
+ }
+
+ /**
+ * Get source data for one video.
+ *
+ * @param array $media_item Product gallery media item.
+ * @param string $size The size of the poster image to retrieve.
+ * @param string $product_title The title of the product used for alt fallback.
+ * @param int $index The media index.
+ * @return array Video source data.
+ */
+ private static function get_video_data( $media_item, $size, $product_title, $index ) {
+ $video_id = isset( $media_item['id'] ) ? (int) $media_item['id'] : 0;
+ $poster_id = ProductMediaGallery::get_video_poster_id( $media_item );
+ $poster = self::get_image_data( $poster_id, $size, $product_title, $index );
+ $video_src = $video_id ? wp_get_attachment_url( $video_id ) : ( $media_item['url'] ?? '' );
+ $mime_type = $video_id ? get_post_mime_type( $video_id ) : '';
+ $settings = isset( $media_item['settings'] ) && is_array( $media_item['settings'] )
+ ? $media_item['settings']
+ : array();
+ $alt = $poster_id ? get_post_meta( $poster_id, '_wp_attachment_image_alt', true ) : '';
+
+ if ( empty( $alt ) && $video_id ) {
+ $video_title = get_post_field( 'post_title', $video_id );
+ $alt = $video_title
+ ? sprintf(
+ /* translators: %s is the video title. */
+ __( 'Video: %s', 'woocommerce' ),
+ $video_title
+ )
+ : '';
+ }
+
+ if ( empty( $alt ) && $product_title ) {
+ $alt = sprintf(
+ /* translators: %s is the product title. */
+ __( 'Product video: %s', 'woocommerce' ),
+ $product_title
+ );
+ }
+
+ return array_merge(
+ $poster,
+ array(
+ 'alt' => $alt,
+ 'id' => $video_id,
+ 'media_type' => 'video',
+ 'mime_type' => $mime_type ? $mime_type : '',
+ 'poster_id' => $poster_id,
+ 'poster_src' => $poster['src'],
+ 'settings' => $settings,
+ 'video_src' => $video_src ? $video_src : '',
+ )
+ );
+ }
+
+ /**
+ * Get the product gallery image items.
+ *
+ * @param \WC_Product $product The product object to retrieve the gallery images for.
+ * @return array[] An array of image media items for the product gallery.
+ */
+ private static function get_product_gallery_image_items( $product ) {
+ return array_map(
+ static function ( $image_id ) {
+ return array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => absint( $image_id ),
+ );
+ },
+ self::get_product_gallery_image_ids( $product )
+ );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 1a88bc909cb..76d59c3b1dd 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -303,6 +304,16 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
+ ProductMediaGallery::FEATURE_ID => array(
+ 'name' => __( 'Product gallery videos', 'woocommerce' ),
+ 'description' => __( 'Enable videos in product galleries.', 'woocommerce' ),
+ 'option_key' => ProductMediaGallery::ENABLE_OPTION_NAME,
+ 'is_experimental' => true,
+ 'enabled_by_default' => false,
+ 'disable_ui' => false,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/ProductGallery/ProductMediaGallery.php b/plugins/woocommerce/src/Internal/ProductGallery/ProductMediaGallery.php
new file mode 100644
index 00000000000..596e394f69b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductGallery/ProductMediaGallery.php
@@ -0,0 +1,764 @@
+<?php
+/**
+ * Product media gallery utilities.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductGallery
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\ProductGallery;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared helpers for product media gallery data.
+ */
+class ProductMediaGallery {
+
+ /**
+ * Product meta key used for beta product video gallery storage.
+ */
+ private const VIDEO_GALLERY_META_KEY = '_wc_video_gallery';
+
+ /**
+ * The feature id used by `FeaturesController` (Settings -> Advanced -> Features).
+ */
+ public const FEATURE_ID = 'product_gallery_videos';
+
+ /**
+ * Option backing the product gallery videos feature toggle.
+ */
+ public const ENABLE_OPTION_NAME = 'woocommerce_feature_product_gallery_videos_enabled';
+
+ /**
+ * Check if product gallery videos are enabled.
+ *
+ * @return bool
+ */
+ public static function is_feature_enabled(): bool {
+ return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
+ }
+
+ /**
+ * Get ordered media gallery items for a product.
+ *
+ * @param WC_Product $product Product object.
+ * @param array $args Optional arguments.
+ * @return array
+ */
+ public static function get_product_media_gallery_items( WC_Product $product, array $args = array() ): array {
+ $args = wp_parse_args(
+ $args,
+ array(
+ 'context' => 'view',
+ 'include_product_image' => true,
+ 'include_placeholder' => false,
+ 'resolve_video_posters' => true,
+ 'deduplicate' => false,
+ )
+ );
+
+ $context = is_string( $args['context'] ) ? $args['context'] : 'view';
+ $include_product_image = (bool) $args['include_product_image'];
+ $media_items = self::get_product_image_media_items( $product, $include_product_image, $context );
+
+ if ( self::is_feature_enabled() ) {
+ $video_gallery = self::normalize_video_gallery_items(
+ self::get_stored_video_gallery_items( $product )
+ );
+
+ if ( ! empty( $video_gallery ) ) {
+ $media_items = self::merge_positioned_video_gallery_items(
+ $media_items,
+ $video_gallery,
+ self::get_video_position_offset( $product, $include_product_image, $context )
+ );
+ }
+ }
+
+ if ( $args['deduplicate'] ) {
+ $media_items = self::deduplicate_media_items( $media_items );
+ }
+
+ if ( $args['resolve_video_posters'] ) {
+ $media_items = self::resolve_video_poster_ids( $media_items );
+ }
+
+ if ( $args['include_placeholder'] && empty( $media_items ) ) {
+ $media_items[] = array(
+ 'media_type' => 'image',
+ 'source_type' => 'placeholder',
+ 'id' => 0,
+ );
+ }
+
+ return array_values( $media_items );
+ }
+
+ /**
+ * Get media gallery items for classic product gallery templates.
+ *
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ *
+ * @param WC_Product $product Product object.
+ * @return array
+ *
+ * @since 11.0.0
+ */
+ public static function get_product_media_gallery_items_for_display( WC_Product $product ): array {
+ $media_items = self::get_product_media_gallery_items(
+ $product,
+ array(
+ 'include_placeholder' => true,
+ 'deduplicate' => true,
+ )
+ );
+
+ self::prime_attachment_caches( $media_items, true );
+
+ return $media_items;
+ }
+
+ /**
+ * Get video gallery items stored for a product.
+ *
+ * @param WC_Product $product Product object.
+ * @return array
+ */
+ public static function get_stored_video_gallery_items( WC_Product $product ): array {
+ $value = $product->get_id()
+ ? get_post_meta( $product->get_id(), self::VIDEO_GALLERY_META_KEY, true )
+ : $product->get_meta( self::VIDEO_GALLERY_META_KEY, true, 'edit' );
+
+ return self::decode_video_gallery_meta( $value );
+ }
+
+ /**
+ * Copy stored video gallery items between products.
+ *
+ * @param WC_Product $source_product Source product.
+ * @param WC_Product $target_product Target product.
+ * @return array Stored video gallery items.
+ */
+ public static function copy_stored_video_gallery_items( WC_Product $source_product, WC_Product $target_product ): array {
+ return self::set_stored_video_gallery_items(
+ $target_product,
+ self::get_stored_video_gallery_items( $source_product )
+ );
+ }
+
+ /**
+ * Normalize and store video gallery items for a product.
+ *
+ * @param WC_Product $product Product object.
+ * @param array $video_gallery Video gallery data.
+ * @return array Stored video gallery items.
+ */
+ public static function set_stored_video_gallery_items(
+ WC_Product $product,
+ array $video_gallery
+ ): array {
+ $video_gallery = self::normalize_video_gallery_items( $video_gallery );
+
+ self::update_video_gallery_meta( $product, $video_gallery );
+
+ return $video_gallery;
+ }
+
+ /**
+ * Normalize media gallery items.
+ *
+ * @param array $media_gallery Media gallery data.
+ * @return array
+ */
+ public static function normalize_media_gallery_items( array $media_gallery ): array {
+ $items = array();
+
+ foreach ( $media_gallery as $index => $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+
+ $media_type = isset( $item['media_type'] ) ? sanitize_key( $item['media_type'] ) : '';
+ $source_type = isset( $item['source_type'] ) ? sanitize_key( $item['source_type'] ) : 'attachment';
+ $attachment_id = isset( $item['id'] ) ? absint( $item['id'] ) : 0;
+
+ if ( 'attachment' !== $source_type || ! $attachment_id ) {
+ continue;
+ }
+
+ if ( 'image' === $media_type ) {
+ if (
+ ! wp_attachment_is_image( $attachment_id ) &&
+ 0 !== strpos( (string) get_post_mime_type( $attachment_id ), 'image/' )
+ ) {
+ continue;
+ }
+
+ $items[] = self::get_image_media_item( $attachment_id );
+ continue;
+ }
+
+ if ( 'video' !== $media_type ) {
+ continue;
+ }
+
+ $video_item = self::normalize_video_gallery_item( $item, absint( $index ) );
+
+ if ( ! empty( $video_item ) ) {
+ $items[] = self::get_video_media_item( $video_item );
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * Get positioned video gallery items from a mixed media gallery.
+ *
+ * The mixed gallery excludes the featured product image. Positions are
+ * stored as 0-based indexes in that gallery and shifted later only when the
+ * composed media gallery includes the featured image.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @return array
+ */
+ public static function get_positioned_video_gallery_items_from_media_gallery( array $media_gallery ): array {
+ $video_gallery = array();
+
+ foreach ( array_values( $media_gallery ) as $position => $media_item ) {
+ if ( ! is_array( $media_item ) || 'video' !== ( $media_item['media_type'] ?? '' ) ) {
+ continue;
+ }
+
+ $media_item['position'] = $position;
+ $video_gallery[] = $media_item;
+ }
+
+ return $video_gallery;
+ }
+
+ /**
+ * Normalize video gallery items.
+ *
+ * @param array $video_gallery Video gallery data.
+ * @return array
+ */
+ public static function normalize_video_gallery_items( array $video_gallery ): array {
+ $items = array();
+
+ foreach ( $video_gallery as $fallback_position => $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+
+ $item = self::normalize_video_gallery_item( $item, absint( $fallback_position ) );
+
+ if ( ! empty( $item ) ) {
+ $items[] = $item;
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * Get image media items from product image props.
+ *
+ * @param WC_Product $product Product object.
+ * @param bool $include_product_image Whether to include the product image.
+ * @param string $context Product read context.
+ * @return array
+ */
+ public static function get_product_image_media_items( WC_Product $product, bool $include_product_image = true, string $context = 'view' ): array {
+ $attachment_ids = array();
+
+ if ( $include_product_image && $product->get_image_id( $context ) ) {
+ $attachment_ids[] = $product->get_image_id( $context );
+ }
+
+ $attachment_ids = array_merge( $attachment_ids, $product->get_gallery_image_ids( $context ) );
+ $media_items = array();
+
+ foreach ( $attachment_ids as $attachment_id ) {
+ $attachment_id = absint( $attachment_id );
+
+ if ( $attachment_id ) {
+ $media_items[] = self::get_image_media_item( $attachment_id );
+ }
+ }
+
+ return $media_items;
+ }
+
+ /**
+ * Get image IDs from media gallery items.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @return array
+ */
+ public static function get_image_ids( array $media_gallery ): array {
+ $image_ids = array();
+
+ foreach ( $media_gallery as $item ) {
+ if (
+ is_array( $item ) &&
+ 'image' === ( $item['media_type'] ?? '' ) &&
+ 'attachment' === ( $item['source_type'] ?? 'attachment' ) &&
+ ! empty( $item['id'] )
+ ) {
+ $image_ids[] = absint( $item['id'] );
+ }
+ }
+
+ return array_values( array_filter( $image_ids ) );
+ }
+
+ /**
+ * Get the poster attachment ID for a gallery video item.
+ *
+ * @param array $media_item Product media gallery item.
+ * @return int
+ */
+ public static function get_video_poster_id( array $media_item ): int {
+ $poster_id = isset( $media_item['poster_id'] ) ? absint( $media_item['poster_id'] ) : 0;
+
+ if ( $poster_id ) {
+ return $poster_id;
+ }
+
+ $attachment_id = isset( $media_item['id'] ) ? absint( $media_item['id'] ) : 0;
+
+ return $attachment_id ? (int) get_post_thumbnail_id( $attachment_id ) : 0;
+ }
+
+ /**
+ * Get HTML for a classic product gallery video.
+ *
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ *
+ * @param array $media_item Product media gallery item.
+ * @param bool $main_video Whether this is the main gallery item.
+ * @return string
+ *
+ * @since 11.0.0
+ */
+ public static function get_gallery_video_html( array $media_item, bool $main_video = false ): string {
+ $attachment_id = isset( $media_item['id'] ) ? absint( $media_item['id'] ) : 0;
+ $video_src = $attachment_id ? wp_get_attachment_url( $attachment_id ) : '';
+
+ if ( ! $video_src ) {
+ return '';
+ }
+
+ /**
+ * Filters whether the single product gallery flexslider is enabled.
+ *
+ * @since 3.0.0
+ *
+ * @param bool $enabled Whether flexslider is enabled.
+ */
+ $flexslider = (bool) apply_filters(
+ 'woocommerce_single_product_flexslider_enabled',
+ get_theme_support( 'wc-product-gallery-slider' )
+ );
+ $gallery_thumbnail = wc_get_image_size( 'gallery_thumbnail' );
+ /**
+ * Filters the product gallery thumbnail image size.
+ *
+ * @since 3.3.2
+ *
+ * @param string|array $size Image size.
+ */
+ $thumbnail_size = apply_filters(
+ 'woocommerce_gallery_thumbnail_size',
+ array( $gallery_thumbnail['width'], $gallery_thumbnail['height'] )
+ );
+ /**
+ * Filters the product gallery image size.
+ *
+ * @since 3.3.2
+ *
+ * @param string|array $size Image size.
+ */
+ $image_size = apply_filters(
+ 'woocommerce_gallery_image_size',
+ $flexslider || $main_video ? 'woocommerce_single' : $thumbnail_size
+ );
+ /**
+ * Filters the product gallery full image size.
+ *
+ * @since 3.3.2
+ *
+ * @param string|array $size Image size.
+ */
+ $full_size = apply_filters(
+ 'woocommerce_gallery_full_size',
+ /**
+ * Filters the product thumbnails large image size.
+ *
+ * @since 3.0.0
+ *
+ * @param string|array $size Image size.
+ */
+ apply_filters( 'woocommerce_product_thumbnails_large_size', 'full' )
+ );
+
+ $poster_id = self::get_video_poster_id( $media_item );
+ $thumbnail_src = $poster_id ? wp_get_attachment_image_src( $poster_id, $thumbnail_size ) : false;
+ $thumbnail_srcset = $poster_id ? wp_get_attachment_image_srcset( $poster_id, $thumbnail_size ) : false;
+ $thumbnail_sizes = $poster_id ? wp_get_attachment_image_sizes( $poster_id, $thumbnail_size ) : false;
+ $poster_src = $poster_id ? wp_get_attachment_image_src( $poster_id, $image_size ) : false;
+ $full_src = $poster_id ? wp_get_attachment_image_src( $poster_id, $full_size ) : false;
+ $alt_text = $poster_id
+ ? trim( wp_strip_all_tags( get_post_meta( $poster_id, '_wp_attachment_image_alt', true ) ) )
+ : '';
+
+ if ( empty( $alt_text ) ) {
+ $video_title = get_post_field( 'post_title', $attachment_id );
+ $alt_text = $video_title
+ ? sprintf(
+ /* translators: %s is the video title. */
+ __( 'Video: %s', 'woocommerce' ),
+ $video_title
+ )
+ : '';
+ }
+
+ $full_width = isset( $full_src[1] ) ? absint( $full_src[1] ) : 1000;
+ $full_height = isset( $full_src[2] ) ? absint( $full_src[2] ) : 1000;
+ $full_src_url = isset( $full_src[0] ) ? $full_src[0] : wc_placeholder_img_src( 'woocommerce_single' );
+ $video_attributes = array(
+ 'autoplay' => 'autoplay',
+ 'class' => $main_video ? 'wp-post-video wp-post-image' : 'wp-post-video',
+ 'src' => $video_src,
+ 'loop' => 'loop',
+ 'muted' => 'muted',
+ 'playsinline' => 'playsinline',
+ 'preload' => 'metadata',
+ 'data-caption' => get_post_field( 'post_excerpt', $attachment_id ),
+ 'data-src' => $full_src_url,
+ 'data-large_image' => $full_src_url,
+ 'data-large_image_width' => $full_width,
+ 'data-large_image_height' => $full_height,
+ 'aria-label' => $alt_text,
+ );
+
+ if ( false !== $poster_src && isset( $poster_src[0] ) ) {
+ $video_attributes['poster'] = $poster_src[0];
+ }
+
+ $thumbnail = isset( $thumbnail_src[0] )
+ ? $thumbnail_src[0]
+ : wc_placeholder_img_src( 'woocommerce_gallery_thumbnail' );
+ $thumbnail_video_src = isset( $thumbnail_src[0] ) ? '' : $video_src;
+
+ return sprintf(
+ '<div data-thumb="%1$s" data-thumb-alt="%2$s" data-thumb-srcset="%3$s" data-thumb-sizes="%4$s" data-thumb-video-src="%5$s" ' .
+ 'class="woocommerce-product-gallery__image woocommerce-product-gallery__video"><a href="%6$s"><video %7$s></video></a></div>',
+ esc_url( $thumbnail ),
+ esc_attr( $alt_text ),
+ esc_attr( $thumbnail_srcset ? $thumbnail_srcset : '' ),
+ esc_attr( $thumbnail_sizes ? $thumbnail_sizes : '' ),
+ esc_url( $thumbnail_video_src ),
+ esc_url( $video_src ),
+ wc_implode_html_attributes(
+ array_filter(
+ $video_attributes,
+ static function ( $value ) {
+ return '' !== $value;
+ }
+ )
+ )
+ );
+ }
+
+ /**
+ * Merge positioned video items into product image media items.
+ *
+ * Video positions are 0-based indexes in the final mixed gallery, excluding
+ * the featured product image. The positions are not anchors to the image-only
+ * gallery; they already describe where videos should land after images and
+ * earlier videos are composed together.
+ *
+ * Example:
+ * - Images: array( 'image A', 'image B', 'image C' )
+ * - Videos: array(
+ * array( 'id' => 10, 'position' => 1 ),
+ * array( 'id' => 11, 'position' => 2 ),
+ * )
+ * - Outcome: array( 'image A', 'video 10', 'video 11', 'image B', 'image C' )
+ *
+ * @param array $media_items Product image media items.
+ * @param array $video_gallery Stored video gallery items.
+ * @param int $position_offset Offset applied when featured image is included.
+ * @return array
+ */
+ private static function merge_positioned_video_gallery_items( array $media_items, array $video_gallery, int $position_offset ): array {
+ foreach ( $video_gallery as $index => $video_item ) {
+ $video_gallery[ $index ]['_sort_order'] = $index;
+ }
+
+ usort(
+ $video_gallery,
+ static function ( $first, $second ) {
+ return array(
+ $first['position'] ?? 0,
+ $first['_sort_order'] ?? 0,
+ ) <=> array(
+ $second['position'] ?? 0,
+ $second['_sort_order'] ?? 0,
+ );
+ }
+ );
+
+ foreach ( $video_gallery as $video_item ) {
+ unset( $video_item['_sort_order'] );
+
+ $position = isset( $video_item['position'] ) ? absint( $video_item['position'] ) : count( $media_items );
+ $position = min( count( $media_items ), $position + $position_offset );
+
+ array_splice( $media_items, $position, 0, array( self::get_video_media_item( $video_item ) ) );
+ }
+
+ return $media_items;
+ }
+
+ /**
+ * Get the positional offset used when composing videos into product media.
+ *
+ * @param WC_Product $product Product object.
+ * @param bool $include_product_image Whether the product image is included.
+ * @param string $context Product read context.
+ * @return int
+ */
+ private static function get_video_position_offset( WC_Product $product, bool $include_product_image, string $context ): int {
+ return $include_product_image && absint( $product->get_image_id( $context ) ) ? 1 : 0;
+ }
+
+ /**
+ * Normalize one video gallery item.
+ *
+ * @param array $item Video gallery item.
+ * @param int $fallback_position Position used when item has no position.
+ * @return array
+ */
+ private static function normalize_video_gallery_item( array $item, int $fallback_position ): array {
+ $media_type = isset( $item['media_type'] ) ? sanitize_key( $item['media_type'] ) : 'video';
+ $source_type = isset( $item['source_type'] ) ? sanitize_key( $item['source_type'] ) : 'attachment';
+ $attachment_id = isset( $item['id'] ) ? absint( $item['id'] ) : 0;
+
+ if ( 'video' !== $media_type || 'attachment' !== $source_type || ! $attachment_id ) {
+ return array();
+ }
+
+ $mime_type = get_post_mime_type( $attachment_id );
+
+ if ( ! is_string( $mime_type ) || 0 !== strpos( $mime_type, 'video/' ) ) {
+ return array();
+ }
+
+ $video_item = array();
+ $poster_id = isset( $item['poster_id'] ) ? absint( $item['poster_id'] ) : 0;
+
+ $video_item['source_type'] = 'attachment';
+ $video_item['id'] = $attachment_id;
+ $video_item['position'] = isset( $item['position'] ) ? absint( $item['position'] ) : $fallback_position;
+
+ if (
+ $poster_id &&
+ (
+ wp_attachment_is_image( $poster_id ) ||
+ 0 === strpos( (string) get_post_mime_type( $poster_id ), 'image/' )
+ )
+ ) {
+ $video_item['poster_id'] = $poster_id;
+ }
+
+ return $video_item;
+ }
+
+ /**
+ * Get attachment IDs represented by media gallery items.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @param bool $include_posters Whether to include resolved video poster IDs.
+ * @return array
+ */
+ private static function get_attachment_ids( array $media_gallery, bool $include_posters = false ): array {
+ $attachment_ids = array();
+
+ foreach ( $media_gallery as $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+
+ if ( 'attachment' === ( $item['source_type'] ?? 'attachment' ) && ! empty( $item['id'] ) ) {
+ $attachment_ids[] = absint( $item['id'] );
+ }
+
+ if (
+ $include_posters &&
+ (
+ ! isset( $item['media_type'] ) ||
+ 'video' === $item['media_type']
+ )
+ ) {
+ $attachment_ids[] = self::get_video_poster_id( $item );
+ }
+ }
+
+ return array_values( array_unique( array_filter( $attachment_ids ) ) );
+ }
+
+ /**
+ * Prime attachment post caches for media gallery items.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @param bool $include_posters Whether to include resolved video poster IDs.
+ * @return void
+ */
+ public static function prime_attachment_caches( array $media_gallery, bool $include_posters = false ): void {
+ $attachment_ids = self::get_attachment_ids( $media_gallery, $include_posters );
+
+ if ( ! empty( $attachment_ids ) ) {
+ _prime_post_caches( $attachment_ids );
+ }
+ }
+
+ /**
+ * Get a normalized image media item.
+ *
+ * @param int $attachment_id Attachment ID.
+ * @return array
+ */
+ private static function get_image_media_item( int $attachment_id ): array {
+ return array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => $attachment_id,
+ );
+ }
+
+ /**
+ * Get a normalized video media item.
+ *
+ * @param array $video_item Stored video gallery item.
+ * @return array
+ */
+ private static function get_video_media_item( array $video_item ): array {
+ unset( $video_item['position'] );
+
+ $video_item['media_type'] = 'video';
+ $video_item['source_type'] = 'attachment';
+
+ return $video_item;
+ }
+
+ /**
+ * Resolve poster IDs for video items.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @return array
+ */
+ private static function resolve_video_poster_ids( array $media_gallery ): array {
+ foreach ( $media_gallery as $index => $media_item ) {
+ if ( ! is_array( $media_item ) || 'video' !== ( $media_item['media_type'] ?? '' ) ) {
+ continue;
+ }
+
+ $poster_id = self::get_video_poster_id( $media_item );
+
+ if ( $poster_id ) {
+ $media_gallery[ $index ]['poster_id'] = $poster_id;
+ }
+ }
+
+ return $media_gallery;
+ }
+
+ /**
+ * Remove duplicate media items by attachment ID.
+ *
+ * @param array $media_gallery Media gallery items.
+ * @return array
+ */
+ private static function deduplicate_media_items( array $media_gallery ): array {
+ $seen_ids = array();
+ $items = array();
+
+ foreach ( $media_gallery as $media_item ) {
+ if ( ! is_array( $media_item ) ) {
+ continue;
+ }
+
+ $attachment_id = isset( $media_item['id'] ) ? absint( $media_item['id'] ) : 0;
+
+ if ( $attachment_id && isset( $seen_ids[ $attachment_id ] ) ) {
+ continue;
+ }
+
+ if ( $attachment_id ) {
+ $seen_ids[ $attachment_id ] = true;
+ }
+
+ $items[] = $media_item;
+ }
+
+ return $items;
+ }
+
+ /**
+ * Decode video gallery post meta.
+ *
+ * @param mixed $value Raw video gallery meta value.
+ * @return array
+ */
+ private static function decode_video_gallery_meta( $value ): array {
+ if ( is_array( $value ) ) {
+ return $value;
+ }
+
+ if ( ! is_string( $value ) || '' === $value ) {
+ return array();
+ }
+
+ $decoded = json_decode( $value, true );
+
+ return is_array( $decoded ) ? $decoded : array();
+ }
+
+ /**
+ * Update video gallery post meta.
+ *
+ * @param WC_Product $product Product object.
+ * @param array $video_gallery Video gallery items.
+ * @return void
+ */
+ private static function update_video_gallery_meta( WC_Product $product, array $video_gallery ): void {
+ if ( empty( $video_gallery ) ) {
+ if ( $product->get_id() ) {
+ delete_post_meta( $product->get_id(), self::VIDEO_GALLERY_META_KEY );
+ } else {
+ $product->delete_meta_data( self::VIDEO_GALLERY_META_KEY );
+ }
+ return;
+ }
+
+ $value = wp_json_encode( $video_gallery );
+
+ if ( false === $value ) {
+ return;
+ }
+
+ if ( $product->get_id() ) {
+ update_post_meta( $product->get_id(), self::VIDEO_GALLERY_META_KEY, wp_slash( $value ) );
+ } else {
+ $product->update_meta_data( self::VIDEO_GALLERY_META_KEY, $value );
+ }
+ }
+}
diff --git a/plugins/woocommerce/templates/single-product/product-image.php b/plugins/woocommerce/templates/single-product/product-image.php
index 9693c7174ce..76dbdb34d51 100644
--- a/plugins/woocommerce/templates/single-product/product-image.php
+++ b/plugins/woocommerce/templates/single-product/product-image.php
@@ -12,10 +12,11 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 10.5.0
+ * @version 11.1.0
*/
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
defined( 'ABSPATH' ) || exit;
@@ -28,11 +29,17 @@ global $product;
$columns = apply_filters( 'woocommerce_product_thumbnails_columns', 4 );
$post_thumbnail_id = $product->get_image_id();
+$media_items = ProductMediaGallery::get_product_media_gallery_items_for_display( $product );
+// The helper returns the full gallery order; product-thumbnails.php renders the remaining items.
+$first_media_item = $media_items[0] ?? array();
+$first_media_id = isset( $first_media_item['id'] ) ? absint( $first_media_item['id'] ) : $post_thumbnail_id;
+$has_media = ! empty( $first_media_item ) && 'placeholder' !== ( $first_media_item['source_type'] ?? '' );
+$is_video = $has_media && 'video' === ( $first_media_item['media_type'] ?? '' );
$wrapper_classes = apply_filters(
'woocommerce_single_product_image_gallery_classes',
array(
'woocommerce-product-gallery',
- 'woocommerce-product-gallery--' . ( $post_thumbnail_id ? 'with-images' : 'without-images' ),
+ 'woocommerce-product-gallery--' . ( $has_media ? 'with-images' : 'without-images' ),
'woocommerce-product-gallery--columns-' . absint( $columns ),
'images',
)
@@ -41,8 +48,10 @@ $wrapper_classes = apply_filters(
<div class="<?php echo esc_attr( implode( ' ', array_map( 'sanitize_html_class', $wrapper_classes ) ) ); ?>" data-columns="<?php echo esc_attr( $columns ); ?>" style="opacity: 0; transition: opacity .25s ease-in-out;">
<div class="woocommerce-product-gallery__wrapper">
<?php
- if ( $post_thumbnail_id ) {
- $html = wc_get_gallery_image_html( $post_thumbnail_id, true );
+ if ( $is_video ) {
+ $html = ProductMediaGallery::get_gallery_video_html( $first_media_item, true );
+ } elseif ( $has_media && $first_media_id ) {
+ $html = wc_get_gallery_image_html( $first_media_id, true );
} else {
// Check for visible children with prices to determine if variation image swapping is possible.
// Using get_visible_children() + get_price() is more efficient than get_available_variations()
@@ -55,7 +64,29 @@ $wrapper_classes = apply_filters(
$html .= '</div>';
}
- echo apply_filters( 'woocommerce_single_product_image_thumbnail_html', $html, $post_thumbnail_id ); // phpcs:disable WordPress.XSS.EscapeOutput.OutputNotEscaped
+ if ( $is_video ) {
+ /**
+ * Filter product video thumbnail HTML string.
+ *
+ * @since 11.0.0
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ *
+ * @param string $html Product video thumbnail HTML string.
+ * @param int $attachment_id Video attachment ID.
+ * @param array $media_item Product media gallery item.
+ */
+ echo apply_filters( 'woocommerce_single_product_video_thumbnail_html', $html, $first_media_id, $first_media_item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ } else {
+ /**
+ * Filter product image thumbnail HTML string.
+ *
+ * @since 1.6.4
+ *
+ * @param string $html Product image thumbnail HTML string.
+ * @param int $attachment_id Attachment ID.
+ */
+ echo apply_filters( 'woocommerce_single_product_image_thumbnail_html', $html, $first_media_id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
do_action( 'woocommerce_product_thumbnails' );
?>
diff --git a/plugins/woocommerce/templates/single-product/product-thumbnails.php b/plugins/woocommerce/templates/single-product/product-thumbnails.php
index ad0890274ad..42d27fba80d 100644
--- a/plugins/woocommerce/templates/single-product/product-thumbnails.php
+++ b/plugins/woocommerce/templates/single-product/product-thumbnails.php
@@ -12,9 +12,11 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 9.8.0
+ * @version 11.1.0
*/
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
+
defined( 'ABSPATH' ) || exit;
// Note: `wc_get_gallery_image_html` was added in WC 3.3.2 and did not exist prior. This check protects against theme overrides being used on older versions of WC.
@@ -28,18 +30,46 @@ if ( ! $product || ! $product instanceof WC_Product ) {
return '';
}
-$attachment_ids = $product->get_gallery_image_ids();
-
-if ( $attachment_ids && $product->get_image_id() ) {
- foreach ( $attachment_ids as $key => $attachment_id ) {
- /**
- * Filter product image thumbnail HTML string.
- *
- * @since 1.6.4
- *
- * @param string $html Product image thumbnail HTML string.
- * @param int $attachment_id Attachment ID.
- */
- echo apply_filters( 'woocommerce_single_product_image_thumbnail_html', wc_get_gallery_image_html( $attachment_id, false, $key ), $attachment_id ); // PHPCS:Ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+$media_items = ProductMediaGallery::get_product_media_gallery_items_for_display( $product );
+
+if ( count( $media_items ) > 1 ) {
+ foreach ( array_slice( $media_items, 1 ) as $key => $media_item ) {
+ $attachment_id = isset( $media_item['id'] ) ? absint( $media_item['id'] ) : 0;
+
+ if ( ! $attachment_id ) {
+ continue;
+ }
+
+ $is_video = 'video' === ( $media_item['media_type'] ?? '' );
+
+ if ( $is_video ) {
+ $html = ProductMediaGallery::get_gallery_video_html( $media_item, false );
+ } else {
+ $html = wc_get_gallery_image_html( $attachment_id, false, $key );
+ }
+
+ if ( $is_video ) {
+ /**
+ * Filter product video thumbnail HTML string.
+ *
+ * @since 11.0.0
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ *
+ * @param string $html Product video thumbnail HTML string.
+ * @param int $attachment_id Video attachment ID.
+ * @param array $media_item Product media gallery item.
+ */
+ echo apply_filters( 'woocommerce_single_product_video_thumbnail_html', $html, $attachment_id, $media_item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ } else {
+ /**
+ * Filter product image thumbnail HTML string.
+ *
+ * @since 1.6.4
+ *
+ * @param string $html Product image thumbnail HTML string.
+ * @param int $attachment_id Attachment ID.
+ */
+ echo apply_filters( 'woocommerce_single_product_image_thumbnail_html', $html, $attachment_id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
}
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/product/data.php b/plugins/woocommerce/tests/legacy/unit-tests/product/data.php
index 7a97b21bd82..e904cd7a967 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/product/data.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/product/data.php
@@ -8,6 +8,7 @@
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
/**
@@ -20,6 +21,14 @@ class WC_Tests_Product_Data extends WC_Unit_Test_Case {
use ArraySubsetAsserts;
+ /**
+ * Tear down test case.
+ */
+ public function tearDown(): void {
+ delete_option( ProductMediaGallery::ENABLE_OPTION_NAME );
+ parent::tearDown();
+ }
+
/**
* Test product setters and getters
*
@@ -110,6 +119,308 @@ class WC_Tests_Product_Data extends WC_Unit_Test_Case {
wp_delete_attachment( $image_id[0], true ); // Remove attachment.
}
+ /**
+ * @testdox Should normalize media gallery items.
+ */
+ public function test_media_gallery_normalizes_items() {
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Gallery image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ )
+ );
+ $poster_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Video poster',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ $media_gallery = ProductMediaGallery::normalize_media_gallery_items(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'id' => (string) $image_id,
+ 'poster_id' => $poster_id,
+ 'settings' => array(
+ 'controls' => false,
+ ),
+ 'extra_field' => 'ignored',
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => (string) $video_id,
+ 'poster_id' => (string) $poster_id,
+ 'settings' => array(
+ 'controls' => 'yes',
+ 'autoplay' => '0',
+ 'loop' => 1,
+ 'muted' => false,
+ 'plays_inline' => 'true',
+ 'preload' => 'metadata',
+ 'ignored' => true,
+ ),
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'embed',
+ 'url' => 'https://www.youtube.com/watch?v=abc123',
+ 'provider_name_slug' => 'YouTube',
+ 'poster_id' => 321,
+ 'embed' => array(
+ 'responsive' => 'yes',
+ 'previewable' => 0,
+ 'ignored' => true,
+ ),
+ ),
+ array(
+ 'media_type' => 'audio',
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ ),
+ array(
+ 'media_type' => 'image',
+ 'source_type' => 'embed',
+ 'url' => 'https://example.com/image.jpg',
+ ),
+ )
+ );
+
+ $this->assertEquals(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => $image_id,
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'poster_id' => $poster_id,
+ ),
+ ),
+ $media_gallery,
+ 'Media gallery should keep only supported normalized items.'
+ );
+
+ wp_delete_attachment( $image_id, true );
+ wp_delete_attachment( $video_id, true );
+ wp_delete_attachment( $poster_id, true );
+ }
+
+ /**
+ * @testdox Should persist video gallery items.
+ */
+ public function test_video_gallery_persists_items() {
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ )
+ );
+ $poster_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Video poster',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_gallery = array(
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 1,
+ 'poster_id' => $poster_id,
+ 'settings' => array(
+ 'controls' => true,
+ 'preload' => 'metadata',
+ ),
+ ),
+ );
+ $expected_video_gallery = array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 1,
+ 'poster_id' => $poster_id,
+ ),
+ );
+ $product = new WC_Product_Simple();
+
+ $product->set_name( 'Product with video gallery' );
+ ProductMediaGallery::set_stored_video_gallery_items( $product, $video_gallery );
+ $product->save();
+
+ $this->assertEquals(
+ $expected_video_gallery,
+ json_decode( get_post_meta( $product->get_id(), '_wc_video_gallery', true ), true ),
+ 'Video gallery should be stored as JSON product meta.'
+ );
+
+ $reloaded_product = wc_get_product( $product->get_id() );
+
+ $this->assertInstanceOf( WC_Product::class, $reloaded_product );
+ $this->assertEquals(
+ $expected_video_gallery,
+ ProductMediaGallery::get_stored_video_gallery_items( $reloaded_product ),
+ 'Reloaded product should expose stored video gallery items through the internal helper.'
+ );
+
+ $product->delete( true );
+ wp_delete_attachment( $video_id, true );
+ wp_delete_attachment( $poster_id, true );
+ }
+
+ /**
+ * @testdox Should save video gallery items from the classic product gallery metabox.
+ */
+ public function test_product_gallery_meta_box_saves_video_gallery_items() {
+ if ( ! class_exists( 'WC_Meta_Box_Product_Images' ) ) {
+ require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-product-images.php';
+ }
+
+ update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Gallery image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ )
+ );
+ $post = get_post( $product->get_id() );
+ $_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+
+ try {
+ // phpcs:disable WordPress.Security.NonceVerification.Missing
+ $_POST['product-type'] = 'simple';
+ $_POST['product_image_gallery'] = (string) $image_id;
+ $_POST['product_media_gallery'] = wp_json_encode(
+ array(
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ ),
+ array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => $image_id,
+ ),
+ )
+ );
+ // phpcs:enable WordPress.Security.NonceVerification.Missing
+
+ WC_Meta_Box_Product_Images::save( $product->get_id(), $post );
+ } finally {
+ $_POST = $_post;
+ }
+
+ $updated_product = wc_get_product( $product->get_id() );
+
+ $this->assertInstanceOf( WC_Product::class, $updated_product );
+ $this->assertSame( array( $image_id ), $updated_product->get_gallery_image_ids() );
+ $this->assertEquals(
+ array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 0,
+ ),
+ ),
+ ProductMediaGallery::get_stored_video_gallery_items( $updated_product )
+ );
+
+ $product->delete( true );
+ wp_delete_attachment( $image_id, true );
+ wp_delete_attachment( $video_id, true );
+ }
+
+ /**
+ * @testdox Should leave stored video gallery data untouched when videos are disabled.
+ */
+ public function test_product_gallery_meta_box_does_not_touch_video_gallery_items_when_videos_disabled() {
+ if ( ! class_exists( 'WC_Meta_Box_Product_Images' ) ) {
+ require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-product-images.php';
+ }
+
+ update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'no' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ )
+ );
+ $new_image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'New gallery image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $post = get_post( $product->get_id() );
+ $_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $video_gallery = array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 0,
+ ),
+ );
+
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ $video_gallery
+ );
+
+ try {
+ // phpcs:disable WordPress.Security.NonceVerification.Missing
+ $_POST['product-type'] = 'simple';
+ $_POST['product_image_gallery'] = (string) $new_image_id;
+ unset( $_POST['product_media_gallery'] );
+ // phpcs:enable WordPress.Security.NonceVerification.Missing
+
+ WC_Meta_Box_Product_Images::save( $product->get_id(), $post );
+ } finally {
+ $_POST = $_post;
+ }
+
+ $updated_product = wc_get_product( $product->get_id() );
+
+ $this->assertInstanceOf( WC_Product::class, $updated_product );
+ $this->assertSame( array( $new_image_id ), $updated_product->get_gallery_image_ids() );
+ $this->assertSame( $video_gallery, ProductMediaGallery::get_stored_video_gallery_items( $updated_product ) );
+ $this->assertTrue( metadata_exists( 'post', $product->get_id(), '_wc_video_gallery' ) );
+
+ $product->delete( true );
+ wp_delete_attachment( $video_id, true );
+ wp_delete_attachment( $new_image_id, true );
+ }
+
/**
* Test the onbackorder stock status.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductGalleryLargeImageTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductGalleryLargeImageTest.php
new file mode 100644
index 00000000000..e41c582c6e2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductGalleryLargeImageTest.php
@@ -0,0 +1,53 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductGalleryLargeImage;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductGalleryLargeImage block type.
+ */
+class ProductGalleryLargeImageTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should replace only the selected product image with video markup.
+ */
+ public function test_replaces_only_selected_product_image_with_video_markup(): void {
+ $image_html = '<figure><a href="https://example.com/product" onclick="return false;" style="display:block"><img class="target" src="https://example.com/target.jpg" style="object-fit:cover;" alt="Target image"><span><img class="inside-link" src="https://example.com/inside-link.jpg" alt="Inside link image"></span></a><img class="outside-link" src="https://example.com/outside-link.jpg" alt="Outside link image"></figure>';
+ $media = array(
+ 'alt' => 'Product video',
+ 'id' => 123,
+ 'video_src' => 'https://example.com/product-video.mp4',
+ );
+
+ $result = $this->replace_product_image_with_video( $image_html, $media );
+
+ $this->assertSame( 1, substr_count( $result, '<video ' ), 'Only one video should be inserted.' );
+ $this->assertSame( 2, substr_count( $result, '<img ' ), 'Unmarked images should remain as images.' );
+ $this->assertStringContainsString( 'src="https://example.com/product-video.mp4"', $result );
+ $this->assertStringNotContainsString( 'src="https://example.com/target.jpg"', $result );
+ $this->assertStringContainsString( 'src="https://example.com/inside-link.jpg"', $result );
+ $this->assertStringContainsString( 'src="https://example.com/outside-link.jpg"', $result );
+ $this->assertStringNotContainsString( 'data-wc-product-gallery-video-placeholder', $result );
+ }
+
+ /**
+ * Replace product image markup with product gallery video markup.
+ *
+ * @param string $image_html Product Image HTML.
+ * @param array $media Video media data.
+ * @return string
+ */
+ private function replace_product_image_with_video( string $image_html, array $media ): string {
+ $reflection = new \ReflectionClass( ProductGalleryLargeImage::class );
+ $sut = $reflection->newInstanceWithoutConstructor();
+ $method = $reflection->getMethod( 'replace_product_image_with_video' );
+
+ $method->setAccessible( true );
+
+ return (string) $method->invoke( $sut, $image_html, $media, array() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
index 519ad9c1e5b..b8e4a58c756 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Blocks\Utils;
use Automattic\WooCommerce\Blocks\Utils\ProductGalleryUtils;
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
use WP_UnitTestCase;
/**
@@ -11,9 +12,10 @@ use WP_UnitTestCase;
*/
class ProductGalleryUtilsTest extends \WP_UnitTestCase {
/**
- * Reset variation gallery feature-flag option leaked by individual tests.
+ * Reset feature-flag options leaked by individual tests.
*/
public function tearDown(): void {
+ delete_option( ProductMediaGallery::ENABLE_OPTION_NAME );
delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
parent::tearDown();
}
@@ -412,4 +414,199 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
return $attachment_id;
}
+
+ /**
+ * Test get_product_gallery_media_data method with legacy gallery images and no featured image.
+ *
+ * @testdox Should preserve legacy gallery images when no featured image is set.
+ */
+ public function test_get_product_gallery_media_data_preserves_legacy_gallery_without_featured_image() {
+ $product = \WC_Helper_Product::create_simple_product();
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Gallery Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ $product->set_image_id( 0 );
+ $product->set_gallery_image_ids( array( $image_id ) );
+ $product->save();
+
+ $media_data = ProductGalleryUtils::get_product_gallery_media_data( $product, 'woocommerce_thumbnail' );
+
+ $this->assertCount( 1, $media_data );
+ $this->assertSame( $image_id, $media_data[0]['id'] );
+ $this->assertSame( 'image', $media_data[0]['media_type'] );
+
+ $product->delete( true );
+ wp_delete_attachment( $image_id, true );
+ }
+
+ /**
+ * Test get_product_gallery_media_data method with composed image and video items.
+ *
+ * @testdox Should include positioned video items in product gallery media data.
+ */
+ public function test_get_product_gallery_media_data_supports_videos() {
+ update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Gallery Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $poster_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Video Poster',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product Video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ 'guid' => 'https://example.com/product-video.mp4',
+ )
+ );
+
+ update_post_meta( $video_id, '_thumbnail_id', $poster_id );
+
+ $product->set_gallery_image_ids( array( $image_id ) );
+ $product->save();
+
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 0,
+ ),
+ )
+ );
+
+ $media_data = ProductGalleryUtils::get_product_gallery_media_data( $product, 'woocommerce_thumbnail' );
+
+ $this->assertCount( 2, $media_data );
+ $this->assertSame( $video_id, $media_data[0]['id'] );
+ $this->assertSame( 'video', $media_data[0]['media_type'] );
+ $this->assertSame( $poster_id, $media_data[0]['poster_id'] );
+ $this->assertSame( 'https://example.com/product-video.mp4', $media_data[0]['video_src'] );
+ $this->assertSame( array(), $media_data[0]['settings'] );
+ $this->assertSame( $image_id, $media_data[1]['id'] );
+ $this->assertSame( 'image', $media_data[1]['media_type'] );
+
+ $product->delete( true );
+ wp_delete_attachment( $image_id, true );
+ wp_delete_attachment( $poster_id, true );
+ wp_delete_attachment( $video_id, true );
+ }
+
+ /**
+ * Test get_product_gallery_media_data method with stored gallery videos.
+ *
+ * @testdox Should keep the featured image before positioned gallery videos.
+ */
+ public function test_get_product_gallery_media_data_prepends_featured_image_to_gallery_only_media() {
+ update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $featured_image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Featured Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product Video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ 'guid' => 'https://example.com/product-video.mp4',
+ )
+ );
+
+ $product->set_image_id( $featured_image_id );
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 0,
+ 'poster_id' => $featured_image_id,
+ ),
+ )
+ );
+
+ $media_data = ProductGalleryUtils::get_product_gallery_media_data( $product, 'woocommerce_thumbnail' );
+
+ $this->assertCount( 2, $media_data );
+ $this->assertSame( $featured_image_id, $media_data[0]['id'] );
+ $this->assertSame( 'image', $media_data[0]['media_type'] );
+ $this->assertSame( $video_id, $media_data[1]['id'] );
+ $this->assertSame( 'video', $media_data[1]['media_type'] );
+
+ $product->delete( true );
+ wp_delete_attachment( $featured_image_id, true );
+ wp_delete_attachment( $video_id, true );
+ }
+
+ /**
+ * Test get_product_gallery_media_count method with stored videos while the feature is disabled.
+ *
+ * @testdox Should ignore stored video media when product gallery videos are disabled.
+ */
+ public function test_get_product_gallery_media_count_ignores_stored_videos_when_feature_disabled() {
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $featured_image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Featured Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $video_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Product Video',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'video/mp4',
+ 'guid' => 'https://example.com/product-video.mp4',
+ )
+ );
+
+ $product->set_image_id( $featured_image_id );
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'source_type' => 'attachment',
+ 'id' => $video_id,
+ 'position' => 0,
+ ),
+ )
+ );
+
+ $media_data = ProductGalleryUtils::get_product_gallery_media_data( $product, 'woocommerce_thumbnail' );
+
+ $this->assertSame( 1, ProductGalleryUtils::get_product_gallery_media_count( $product ) );
+ $this->assertCount( 1, $media_data );
+ $this->assertSame( $featured_image_id, $media_data[0]['id'] );
+ $this->assertSame( 'image', $media_data[0]['media_type'] );
+
+ $product->delete( true );
+ wp_delete_attachment( $featured_image_id, true );
+ wp_delete_attachment( $video_id, true );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductGallery/ProductMediaGalleryTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductGallery/ProductMediaGalleryTest.php
new file mode 100644
index 00000000000..8baf11db5c6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductGallery/ProductMediaGalleryTest.php
@@ -0,0 +1,303 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductGallery;
+
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
+use WC_Product_Simple;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductMediaGallery class.
+ */
+class ProductMediaGalleryTest extends WC_Unit_Test_Case {
+
+ /**
+ * Enable the product gallery videos feature.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'yes' );
+ }
+
+ /**
+ * Clean up feature option state.
+ */
+ public function tearDown(): void {
+ delete_option( ProductMediaGallery::ENABLE_OPTION_NAME );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should merge positioned videos into gallery images.
+ */
+ public function test_merges_positioned_videos_into_gallery_images(): void {
+ $product = new WC_Product_Simple();
+ $image_ids = array(
+ $this->create_attachment( 'Image A', 'image/jpeg' ),
+ $this->create_attachment( 'Image B', 'image/jpeg' ),
+ $this->create_attachment( 'Image C', 'image/jpeg' ),
+ );
+ $video_ids = array(
+ $this->create_attachment( 'Video 1', 'video/mp4' ),
+ $this->create_attachment( 'Video 2', 'video/mp4' ),
+ );
+
+ $product->set_gallery_image_ids( $image_ids );
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'id' => $video_ids[0],
+ 'position' => 1,
+ ),
+ array(
+ 'id' => $video_ids[1],
+ 'position' => 2,
+ ),
+ )
+ );
+
+ $media_items = ProductMediaGallery::get_product_media_gallery_items(
+ $product,
+ array(
+ 'include_product_image' => false,
+ 'resolve_video_posters' => false,
+ )
+ );
+
+ $this->assertSame(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[0],
+ ),
+ array(
+ 'media_type' => 'video',
+ 'id' => $video_ids[0],
+ ),
+ array(
+ 'media_type' => 'video',
+ 'id' => $video_ids[1],
+ ),
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[1],
+ ),
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[2],
+ ),
+ ),
+ $this->get_media_item_summary( $media_items ),
+ 'Videos should be placed at their final mixed gallery positions.'
+ );
+ }
+
+ /**
+ * @testdox Should offset positioned videos when the product image is included.
+ */
+ public function test_offsets_positioned_videos_when_product_image_is_included(): void {
+ $product = new WC_Product_Simple();
+ $product_image_id = $this->create_attachment( 'Product image', 'image/jpeg' );
+ $image_ids = array(
+ $this->create_attachment( 'Image A', 'image/jpeg' ),
+ $this->create_attachment( 'Image B', 'image/jpeg' ),
+ $this->create_attachment( 'Image C', 'image/jpeg' ),
+ );
+ $video_ids = array(
+ $this->create_attachment( 'Video 1', 'video/mp4' ),
+ $this->create_attachment( 'Video 2', 'video/mp4' ),
+ );
+
+ $product->set_image_id( $product_image_id );
+ $product->set_gallery_image_ids( $image_ids );
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'id' => $video_ids[0],
+ 'position' => 1,
+ ),
+ array(
+ 'id' => $video_ids[1],
+ 'position' => 2,
+ ),
+ )
+ );
+
+ $media_items = ProductMediaGallery::get_product_media_gallery_items(
+ $product,
+ array(
+ 'include_product_image' => true,
+ 'resolve_video_posters' => false,
+ )
+ );
+
+ $this->assertSame(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'id' => $product_image_id,
+ ),
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[0],
+ ),
+ array(
+ 'media_type' => 'video',
+ 'id' => $video_ids[0],
+ ),
+ array(
+ 'media_type' => 'video',
+ 'id' => $video_ids[1],
+ ),
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[1],
+ ),
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[2],
+ ),
+ ),
+ $this->get_media_item_summary( $media_items ),
+ 'Videos should keep positions relative to the gallery after the featured image.'
+ );
+ }
+
+ /**
+ * @testdox Should clamp positioned videos when gallery images are removed.
+ */
+ public function test_clamps_positioned_videos_when_gallery_images_are_removed(): void {
+ $product = new WC_Product_Simple();
+ $image_ids = array(
+ $this->create_attachment( 'Image A', 'image/jpeg' ),
+ $this->create_attachment( 'Image B', 'image/jpeg' ),
+ $this->create_attachment( 'Image C', 'image/jpeg' ),
+ );
+ $video_id = $this->create_attachment( 'Video', 'video/mp4' );
+
+ $product->set_gallery_image_ids( $image_ids );
+ ProductMediaGallery::set_stored_video_gallery_items(
+ $product,
+ array(
+ array(
+ 'id' => $video_id,
+ 'position' => 3,
+ ),
+ )
+ );
+
+ $product->set_gallery_image_ids( array( $image_ids[0] ) );
+
+ $media_items = ProductMediaGallery::get_product_media_gallery_items(
+ $product,
+ array(
+ 'include_product_image' => false,
+ 'resolve_video_posters' => false,
+ )
+ );
+
+ $this->assertSame(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'id' => $image_ids[0],
+ ),
+ array(
+ 'media_type' => 'video',
+ 'id' => $video_id,
+ ),
+ ),
+ $this->get_media_item_summary( $media_items ),
+ 'Videos should move to the end when their stored position is past the remaining images.'
+ );
+ }
+
+ /**
+ * @testdox Should extract positioned videos from a mixed gallery.
+ */
+ public function test_extracts_positioned_videos_from_mixed_gallery(): void {
+ $video_gallery = ProductMediaGallery::get_positioned_video_gallery_items_from_media_gallery(
+ array(
+ array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => 101,
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => 201,
+ ),
+ array(
+ 'media_type' => 'image',
+ 'source_type' => 'attachment',
+ 'id' => 102,
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => 202,
+ ),
+ )
+ );
+
+ $this->assertSame(
+ array(
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => 201,
+ 'position' => 1,
+ ),
+ array(
+ 'media_type' => 'video',
+ 'source_type' => 'attachment',
+ 'id' => 202,
+ 'position' => 3,
+ ),
+ ),
+ $video_gallery,
+ 'Video positions should match their indexes in the mixed gallery.'
+ );
+ }
+
+ /**
+ * Get media item type and ID pairs.
+ *
+ * @param array $media_items Media items.
+ * @return array
+ */
+ private function get_media_item_summary( array $media_items ): array {
+ return array_map(
+ static function ( array $media_item ): array {
+ return array(
+ 'media_type' => $media_item['media_type'],
+ 'id' => $media_item['id'],
+ );
+ },
+ $media_items
+ );
+ }
+
+ /**
+ * Create a test attachment.
+ *
+ * @param string $title Attachment title.
+ * @param string $mime_type Attachment MIME type.
+ * @return int
+ */
+ private function create_attachment( string $title, string $mime_type ): int {
+ return wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => $mime_type,
+ )
+ );
+ }
+}