diff --git a/dotcom-rendering/src/components/SlideshowCarousel.island.tsx b/dotcom-rendering/src/components/SlideshowCarousel.island.tsx index 92fa9c7258c..6ec90dbef8d 100644 --- a/dotcom-rendering/src/components/SlideshowCarousel.island.tsx +++ b/dotcom-rendering/src/components/SlideshowCarousel.island.tsx @@ -16,11 +16,44 @@ import { useEffect, useRef, useState } from 'react'; import { getZIndex } from '../lib/getZIndex'; import { takeFirst } from '../lib/tuple'; import { palette } from '../palette'; -import type { DCRSlideshowImage } from '../types/front'; +import type { DCRSlideshowMedia } from '../types/front'; import type { MediaSizeType } from './Card/components/MediaWrapper'; import { CardPicture } from './CardPicture'; import { SlideshowCarouselScrollingDots } from './SlideshowCarouselScrollingDots'; +/** + * TEMPORARY SPIKE HACK — remove before merging. + * + * A hardcoded video slide so we can see what mixed media feels like on a real + * front locally. It gets injected as the third slide in every slideshow (see + * where `SPIKE_VIDEO_SLIDE` is spliced into the slides array below). + * + * This is the same asset used in the Storybook stories. + */ +const SPIKE_VIDEO_SLIDE: DCRSlideshowMedia = { + type: 'video', + videoSrc: + 'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4', + posterSrc: + 'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg', + caption: 'A self-hosted looping video slide (spike)', +}; + +/** + * Spike helpers for supporting mixed media (images and videos) in a slideshow. + * A slide is treated as an image unless it is explicitly typed as a video. + */ +const isVideoSlide = ( + slide: DCRSlideshowMedia, +): slide is Extract => + slide.type === 'video'; + +const getSlideKey = (slide: DCRSlideshowMedia): string => + isVideoSlide(slide) ? slide.videoSrc : slide.imageSrc; + +const getSlideCaption = (slide: DCRSlideshowMedia): string | undefined => + isVideoSlide(slide) ? slide.caption : slide.imageCaption; + const themeButton: Partial = { borderTertiary: palette('--carousel-chevron-border'), textTertiary: palette('--carousel-chevron'), @@ -118,8 +151,19 @@ const mediaOverlayStyles = css` width: 100%; `; +const videoStyles = css` + display: block; + width: 100%; + aspect-ratio: 5 / 4; + object-fit: cover; +`; + type Props = { - images: readonly DCRSlideshowImage[]; + /** + * Slides may be images or videos. The prop retains the `images` name for + * backwards compatibility while we spike mixed-media slideshows. + */ + images: readonly DCRSlideshowMedia[]; imageSize: MediaSizeType; hasNavigationBackgroundColour: boolean; linkTo: string; @@ -212,10 +256,23 @@ export const SlideshowCarousel = ({ }, []); /** - * Restrict slideshow to a maximum of 10 images + * Restrict slideshow to a maximum of 10 slides */ - const slideshowImages = takeFirst(images, 10); - const slideshowImageCount = slideshowImages.length; + const passedSlides = takeFirst(images, 10); + + /** + * TEMPORARY SPIKE HACK — remove before merging. + * + * Inject the hardcoded video slide as the third slide (index 2) so we can + * preview mixed media on a real front. Falls back to appending if there are + * fewer than two passed slides. + */ + const slides: readonly DCRSlideshowMedia[] = [ + ...passedSlides.slice(0, 2), + SPIKE_VIDEO_SLIDE, + ...passedSlides.slice(2), + ]; + const slideCount = slides.length; return (
- {slideshowImages.map((image, index) => { + {slides.map((slide, index) => { const loading = index > 0 ? 'lazy' : 'eager'; + const caption = getSlideCaption(slide); return (
  • - - {!!image.imageCaption && ( + {isVideoSlide(slide) ? ( + + ) : ( + + )} + {!!caption && (
    - {image.imageCaption} + {caption}
    )}
    @@ -270,7 +351,7 @@ export const SlideshowCarousel = ({ - {slideshowImageCount > 1 && ( + {slideCount > 1 && (
    @@ -297,7 +378,7 @@ export const SlideshowCarousel = ({ } size="small" disabled={!previousButtonEnabled} - aria-label="Previous image" + aria-label="Previous slide" // TODO: data-link-name="slideshow carousel left chevron" /> @@ -314,7 +395,7 @@ export const SlideshowCarousel = ({ } size="small" disabled={!nextButtonEnabled} - aria-label="Next image" + aria-label="Next slide" // TODO: data-link-name="slideshow carousel right chevron" />
    diff --git a/dotcom-rendering/src/components/SlideshowCarousel.stories.tsx b/dotcom-rendering/src/components/SlideshowCarousel.stories.tsx index 8e3d9ef9dd7..f04c195aa57 100644 --- a/dotcom-rendering/src/components/SlideshowCarousel.stories.tsx +++ b/dotcom-rendering/src/components/SlideshowCarousel.stories.tsx @@ -3,7 +3,11 @@ import { breakpoints, space, textSans17 } from '@guardian/source/foundations'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; import type { ReactNode } from 'react'; import { palette } from '../palette'; -import type { DCRContainerPalette, DCRSlideshowImage } from '../types/front'; +import type { + DCRContainerPalette, + DCRSlideshowImage, + DCRSlideshowMedia, +} from '../types/front'; import { ContainerOverrides } from './ContainerOverrides'; import { SlideshowCarousel } from './SlideshowCarousel.island'; @@ -46,6 +50,26 @@ const images = [ }, ] as const satisfies readonly DCRSlideshowImage[]; +/** + * Spike: video slides that can be mixed into a slideshow. Reuses the self-hosted + * loop video assets already used elsewhere on fronts. + */ +const videoSlide = { + type: 'video', + videoSrc: + 'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4', + posterSrc: + 'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg', + caption: 'A self-hosted looping video slide', +} as const satisfies DCRSlideshowMedia; + +const mixedMedia = [ + images[0], + videoSlide, + images[1], + images[2], +] as const satisfies readonly DCRSlideshowMedia[]; + const Wrapper = ({ children, heading, @@ -120,6 +144,18 @@ export const WithOneImage = { }, } satisfies Story; +export const WithVideoAndImages = { + args: { + images: mixedMedia, + }, +} satisfies Story; + +export const WithOneVideo = { + args: { + images: [videoSlide], + }, +} satisfies Story; + const containerPalettes = [ 'InvestigationPalette', 'LongRunningPalette', diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index d1763598525..61da2504ecb 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -109,10 +109,37 @@ export type DCRFrontCard = { }; export type DCRSlideshowImage = { + /** + * Discriminant for slideshow media. Optional for backwards compatibility: + * a slide without a `type` is treated as an image. + */ + type?: 'image'; imageSrc: string; imageCaption?: string; }; +/** + * Spike: a video slide within a media slideshow. + * + * Kept intentionally lightweight while we explore supporting non-image media in + * slideshows. Videos autoplay muted and loop, matching the "loop video" style + * used elsewhere on fronts. + */ +export type DCRSlideshowVideo = { + type: 'video'; + videoSrc: string; + /** MIME type of the video source, defaults to `video/mp4` when rendered. */ + mimeType?: string; + /** Poster image shown before the video loads. */ + posterSrc?: string; + caption?: string; +}; + +/** + * A single slide in a media slideshow, which may be an image or a video. + */ +export type DCRSlideshowMedia = DCRSlideshowImage | DCRSlideshowVideo; + export type DCRSnapType = { embedHtml?: string; embedCss?: string;