From 347fa0b44c438618a54b5c8d6bb3a71b8babc7da Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 09:39:49 +0000 Subject: [PATCH 01/10] Add a handleFullscreenClick function that toggles the fullscreen status of a video. https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API/Guide --- .../src/components/SelfHostedVideo.importable.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index be8e73367b9..d9620dd2c09 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -616,6 +616,20 @@ export const SelfHostedVideo = ({ } }; + const handleFullscreenClick = async (event: React.SyntheticEvent) => { + void submitClickComponentEvent(event.currentTarget, renderingTarget); + event.stopPropagation(); // Don't pause the video + const video = vidRef.current; + + if (!video) return; + + if (!document.fullscreenElement) { + await video.requestFullscreen(); + } else { + await document.exitFullscreen(); + } + }; + /** * If the video was paused and we know that it wasn't paused by the user * or the intersection observer, we can deduce that it was paused by the From f2c96a8c7d2e076d94ba0d5b12972da8702363e4 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 09:44:30 +0000 Subject: [PATCH 02/10] Add a full screen button that appears alongside the audio button when both are visible at once --- .../components/SelfHostedVideo.importable.tsx | 2 + .../src/components/SelfHostedVideoPlayer.tsx | 96 ++++++++++++------- dotcom-rendering/src/paletteDeclarations.ts | 14 +-- 3 files changed, 73 insertions(+), 39 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index d9620dd2c09..b6ba38daf34 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -833,11 +833,13 @@ export const SelfHostedVideo = ({ handleAudioClick={handleAudioClick} handleKeyDown={handleKeyDown} handlePause={handlePause} + handleFullscreenClick={handleFullscreenClick} onError={onError} AudioIcon={hasAudio ? AudioIcon : null} preloadPartialData={preloadPartialData} showPlayIcon={showPlayIcon} showProgressBar={showProgressBar} + showFullscreenIcon={videoStyle === 'Default'} subtitleSource={subtitleSource} subtitleSize={subtitleSize} controlsPosition={controlsPosition} diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 9b9b3e334df..dc4ce2937c6 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -6,6 +6,7 @@ import { textSans20, } from '@guardian/source/foundations'; import type { IconProps } from '@guardian/source/react-components'; +import { SvgArrowExpand } from '@guardian/source/react-components'; import type { Dispatch, ReactElement, @@ -60,29 +61,32 @@ const playIconStyles = css` background: none; padding: 0; `; - -const audioButtonStyles = (position: ControlsPosition) => css` - border: none; - background: none; - padding: 0; +const controlContainerStyles = (position: ControlsPosition) => css` position: absolute; - cursor: pointer; - + display: flex; + gap: ${space[2]}px; right: ${space[2]}px; /* Take into account the progress bar height */ ${position === 'bottom' && `bottom: ${space[3]}px;`} ${position === 'top' && `top: ${space[2]}px;`} `; -const audioIconContainerStyles = css` +const buttonStyles = css` + border: none; + background: none; + padding: 0; + cursor: pointer; +`; + +const iconContainerStyles = css` width: ${space[8]}px; height: ${space[8]}px; display: flex; justify-content: center; align-items: center; - background-color: ${palette('--video-audio-icon-background')}; + background-color: ${palette('--video-icon-background')}; border-radius: 50%; - border: 1px solid ${palette('--video-audio-icon-border')}; + border: 1px solid ${palette('--video-icon-border')}; `; export const PLAYER_STATES = [ @@ -123,12 +127,14 @@ type Props = { handleAudioClick: (event: SyntheticEvent) => void; handleKeyDown: (event: React.KeyboardEvent) => void; handlePause: (event: SyntheticEvent) => void; + handleFullscreenClick?: (event: SyntheticEvent) => Promise; onError: (event: SyntheticEvent) => void; AudioIcon: ((iconProps: IconProps) => JSX.Element) | null; posterImage?: string; preloadPartialData: boolean; showPlayIcon: boolean; showProgressBar: boolean; + showFullscreenIcon: boolean; subtitleSource?: string; subtitleSize?: SubtitleSize; controlsPosition: ControlsPosition; @@ -167,11 +173,13 @@ export const SelfHostedVideoPlayer = forwardRef( handleAudioClick, handleKeyDown, handlePause, + handleFullscreenClick, onError, AudioIcon, preloadPartialData, showPlayIcon, showProgressBar, + showFullscreenIcon, subtitleSource, subtitleSize, controlsPosition, @@ -285,30 +293,54 @@ export const SelfHostedVideoPlayer = forwardRef( duration={ref.current!.duration} /> )} - {AudioIcon && ( - - )} +
+ +
+ + )} + {showFullscreenIcon && ( + + )} + )} diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index cc604489aea..bfc2db5fc99 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -8333,22 +8333,22 @@ const paletteColours = { light: () => sourcePalette.neutral[46], dark: () => sourcePalette.neutral[60], }, - '--video-audio-icon': { + '--video-background': { + light: () => sourcePalette.neutral[93], + dark: () => sourcePalette.neutral[93], + }, + '--video-icon': { light: () => sourcePalette.neutral[100], dark: () => sourcePalette.neutral[100], }, - '--video-audio-icon-background': { + '--video-icon-background': { light: () => transparentColour(sourcePalette.neutral[7], 0.7), dark: () => transparentColour(sourcePalette.neutral[7], 0.7), }, - '--video-audio-icon-border': { + '--video-icon-border': { light: () => sourcePalette.neutral[60], dark: () => sourcePalette.neutral[60], }, - '--video-background': { - light: () => sourcePalette.neutral[93], - dark: () => sourcePalette.neutral[93], - }, '--video-progress-bar-background': { light: () => transparentColour(sourcePalette.neutral[7], 0.7), dark: () => transparentColour(sourcePalette.neutral[7], 0.7), From 0deed4b9cd2e15c6b78c359aa591837879945518 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 09:45:22 +0000 Subject: [PATCH 03/10] Add an interaction story --- .../components/SelfHostedVideo.stories.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx b/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx index 51a07677a85..31bf4d56a08 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx @@ -151,3 +151,40 @@ export const InteractionObserver: Story = { await expect(canvas.queryByTestId('play-icon')).not.toBeInTheDocument(); }, } satisfies Story; + +export const FullscreenOpen: Story = { + ...Loop5to4, + name: 'Open fullscreen', + args: { + ...Loop5to4.args, + videoStyle: 'Default', + }, + parameters: { + test: { + // The following error is received without this flag: "TypeError: ophan.trackClickComponentEvent is not a function" + dangerouslyIgnoreUnhandledErrors: true, + }, + }, + play: async ({ canvasElement }) => { + /** + * Ideally, this interaction test would open and close fullscreen. + * However, the Fullscreen API is not implemented in jsdom, so + * document.fullscreenElement will always be null regardless of what the + * component does. Instead, we spy on requestFullscreen to ensure + * that the correct handler is invoked when the fullscreen button is clicked. + */ + + const canvas = within(canvasElement); + + const requestFullscreenSpy = spyOn( + HTMLElement.prototype, + 'requestFullscreen', + ); + + await canvas.findByTestId('fullscreen-icon'); + + await userEvent.click(canvas.getByTestId('fullscreen-icon')); + + await expect(requestFullscreenSpy).toHaveBeenCalled(); + }, +} satisfies Story; From 49af7509641e0f74b5c62917c8083fb5d3a86ae0 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 13:45:37 +0000 Subject: [PATCH 04/10] Add specific handling for ios as requestFullscreen api is not supported yet on safari mobile --- .../components/SelfHostedVideo.importable.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index b6ba38daf34..058d67925db 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -623,11 +623,32 @@ export const SelfHostedVideo = ({ if (!video) return; - if (!document.fullscreenElement) { - await video.requestFullscreen(); - } else { + if ('webkitEnterFullscreen' in video) { + /** + * Fullscreen api is not supported by Safari mobile + * + * webkit fullscreen methods are not part of the standard HTMLVideoElement + * type definition as they are iOS only. + * We need to extend the type expect these handlers when we're on iOS to keep TS happy. + * @see https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1633500-webkitenterfullscreen + */ + const webkitVideo = video as HTMLVideoElement & { + webkitDisplayingFullscreen: () => boolean; + webkitEnterFullscreen: () => void; + webkitExitFullscreen: () => void; + }; + + if (webkitVideo.webkitDisplayingFullscreen()) { + return webkitVideo.webkitExitFullscreen(); + } else { + return webkitVideo.webkitEnterFullscreen(); + } + } + + if (document.fullscreenElement) { await document.exitFullscreen(); } + await video.requestFullscreen(); }; /** From 2771bffb6db67e98c814f55c821ac740ca472e2f Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 14:23:23 +0000 Subject: [PATCH 05/10] Extract ios handler check similar to doesVideoHaveAudio --- .../components/SelfHostedVideo.importable.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index 058d67925db..4a49de0ffe9 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -198,6 +198,18 @@ const doesVideoHaveAudio = (video: HTMLVideoElement): boolean => ('audioTracks' in video && Boolean((video.audioTracks as { length: number }).length)); +/** + * The Fullscreen api is not supported by Safari mobile, + * so we need to check if we have ios html video element properties we can use instead. + * */ +const shouldFullscreenOnIOS = (video: HTMLVideoElement): boolean => { + return ( + 'webkitDisplayingFullscreen' in video && + 'webkitEnterFullscreen' in video && + 'webkitExitFullscreen' in video + ); +}; + /** * Ensure the aspect ratio of the video is within the boundary, if specified. * For example, we may not want to render a square video inside a 4:5 feature card. @@ -623,22 +635,20 @@ export const SelfHostedVideo = ({ if (!video) return; - if ('webkitEnterFullscreen' in video) { - /** - * Fullscreen api is not supported by Safari mobile - * + if (shouldFullscreenOnIOS(video)) { + /*** * webkit fullscreen methods are not part of the standard HTMLVideoElement * type definition as they are iOS only. * We need to extend the type expect these handlers when we're on iOS to keep TS happy. * @see https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1633500-webkitenterfullscreen */ const webkitVideo = video as HTMLVideoElement & { - webkitDisplayingFullscreen: () => boolean; + webkitDisplayingFullscreen: boolean; webkitEnterFullscreen: () => void; webkitExitFullscreen: () => void; }; - if (webkitVideo.webkitDisplayingFullscreen()) { + if (webkitVideo.webkitDisplayingFullscreen) { return webkitVideo.webkitExitFullscreen(); } else { return webkitVideo.webkitEnterFullscreen(); From 746d8520c95f391798d012ae5203e1257711b14b Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 15:45:48 +0000 Subject: [PATCH 06/10] Improve naming --- .../src/components/SelfHostedVideo.importable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index 4a49de0ffe9..d55ad718147 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -200,9 +200,9 @@ const doesVideoHaveAudio = (video: HTMLVideoElement): boolean => /** * The Fullscreen api is not supported by Safari mobile, - * so we need to check if we have ios html video element properties we can use instead. + * so we need to check if we have access to the webkit api we can use instead. * */ -const shouldFullscreenOnIOS = (video: HTMLVideoElement): boolean => { +const shouldUseWebkitFullscreen = (video: HTMLVideoElement): boolean => { return ( 'webkitDisplayingFullscreen' in video && 'webkitEnterFullscreen' in video && @@ -635,7 +635,7 @@ export const SelfHostedVideo = ({ if (!video) return; - if (shouldFullscreenOnIOS(video)) { + if (shouldUseWebkitFullscreen(video)) { /*** * webkit fullscreen methods are not part of the standard HTMLVideoElement * type definition as they are iOS only. From 7b50eff7e3076ab1755ff9692d1327e007c0b8b4 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 26 Feb 2026 16:57:26 +0000 Subject: [PATCH 07/10] Remove async from the fullscreen function signature by void-ing the promises inline --- .../src/components/SelfHostedVideo.importable.tsx | 6 +++--- dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index d55ad718147..20361095178 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -628,7 +628,7 @@ export const SelfHostedVideo = ({ } }; - const handleFullscreenClick = async (event: React.SyntheticEvent) => { + const handleFullscreenClick = (event: React.SyntheticEvent) => { void submitClickComponentEvent(event.currentTarget, renderingTarget); event.stopPropagation(); // Don't pause the video const video = vidRef.current; @@ -656,9 +656,9 @@ export const SelfHostedVideo = ({ } if (document.fullscreenElement) { - await document.exitFullscreen(); + void document.exitFullscreen(); } - await video.requestFullscreen(); + void video.requestFullscreen(); }; /** diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index dc4ce2937c6..205dfc55241 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -127,7 +127,7 @@ type Props = { handleAudioClick: (event: SyntheticEvent) => void; handleKeyDown: (event: React.KeyboardEvent) => void; handlePause: (event: SyntheticEvent) => void; - handleFullscreenClick?: (event: SyntheticEvent) => Promise; + handleFullscreenClick?: (event: SyntheticEvent) => void; onError: (event: SyntheticEvent) => void; AudioIcon: ((iconProps: IconProps) => JSX.Element) | null; posterImage?: string; @@ -321,9 +321,7 @@ export const SelfHostedVideoPlayer = forwardRef( {showFullscreenIcon && (