diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index be8e73367b9..0be269000b8 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 access to the webkit api we can use instead. + * */ +const shouldUseWebkitFullscreen = (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. @@ -616,6 +628,39 @@ export const SelfHostedVideo = ({ } }; + const handleFullscreenClick = (event: React.SyntheticEvent) => { + void submitClickComponentEvent(event.currentTarget, renderingTarget); + event.stopPropagation(); // Don't pause the video + const video = vidRef.current; + + if (!video) return; + + if (shouldUseWebkitFullscreen(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; + webkitEnterFullscreen: () => void; + webkitExitFullscreen: () => void; + }; + + if (webkitVideo.webkitDisplayingFullscreen) { + return webkitVideo.webkitExitFullscreen(); + } else { + return webkitVideo.webkitEnterFullscreen(); + } + } + + if (document.fullscreenElement) { + void document.exitFullscreen(); + } + void video.requestFullscreen(); + }; + /** * 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 @@ -819,6 +864,7 @@ export const SelfHostedVideo = ({ handleAudioClick={handleAudioClick} handleKeyDown={handleKeyDown} handlePause={handlePause} + handleFullscreenClick={handleFullscreenClick} onError={onError} AudioIcon={hasAudio ? AudioIcon : null} preloadPartialData={preloadPartialData} 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; diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 9b9b3e334df..3e8eb385e87 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,33 @@ 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; + flex-direction: column; + 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,6 +128,7 @@ type Props = { handleAudioClick: (event: SyntheticEvent) => void; handleKeyDown: (event: React.KeyboardEvent) => void; handlePause: (event: SyntheticEvent) => void; + handleFullscreenClick?: (event: SyntheticEvent) => void; onError: (event: SyntheticEvent) => void; AudioIcon: ((iconProps: IconProps) => JSX.Element) | null; posterImage?: string; @@ -167,6 +173,7 @@ export const SelfHostedVideoPlayer = forwardRef( handleAudioClick, handleKeyDown, handlePause, + handleFullscreenClick, onError, AudioIcon, preloadPartialData, @@ -191,6 +198,8 @@ export const SelfHostedVideoPlayer = forwardRef( ref.current && isPlayable; + const showFullscreenIcon = videoStyle === 'Default'; + const dataLinkName = `gu-video-${videoStyle}-${ showPlayIcon ? 'play' : 'pause' }-${atomId}`; @@ -285,30 +294,52 @@ 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 fa61929eeaf..8cb053ae763 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -8389,22 +8389,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),