Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions dotcom-rendering/src/components/SelfHostedVideo.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -819,6 +864,7 @@ export const SelfHostedVideo = ({
handleAudioClick={handleAudioClick}
handleKeyDown={handleKeyDown}
handlePause={handlePause}
handleFullscreenClick={handleFullscreenClick}
onError={onError}
AudioIcon={hasAudio ? AudioIcon : null}
preloadPartialData={preloadPartialData}
Expand Down
37 changes: 37 additions & 0 deletions dotcom-rendering/src/components/SelfHostedVideo.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
95 changes: 63 additions & 32 deletions dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -123,6 +128,7 @@ type Props = {
handleAudioClick: (event: SyntheticEvent) => void;
handleKeyDown: (event: React.KeyboardEvent<HTMLVideoElement>) => void;
handlePause: (event: SyntheticEvent) => void;
handleFullscreenClick?: (event: SyntheticEvent) => void;
onError: (event: SyntheticEvent<HTMLVideoElement>) => void;
AudioIcon: ((iconProps: IconProps) => JSX.Element) | null;
posterImage?: string;
Expand Down Expand Up @@ -167,6 +173,7 @@ export const SelfHostedVideoPlayer = forwardRef(
handleAudioClick,
handleKeyDown,
handlePause,
handleFullscreenClick,
onError,
AudioIcon,
preloadPartialData,
Expand All @@ -191,6 +198,8 @@ export const SelfHostedVideoPlayer = forwardRef(
ref.current &&
isPlayable;

const showFullscreenIcon = videoStyle === 'Default';

const dataLinkName = `gu-video-${videoStyle}-${
showPlayIcon ? 'play' : 'pause'
}-${atomId}`;
Expand Down Expand Up @@ -285,30 +294,52 @@ export const SelfHostedVideoPlayer = forwardRef(
duration={ref.current!.duration}
/>
)}
{AudioIcon && (
<button
type="button"
onClick={handleAudioClick}
css={audioButtonStyles(controlsPosition)}
data-link-name={`gu-video-loop-${
isMuted ? 'unmute' : 'mute'
}-${atomId}`}
>
<div
css={audioIconContainerStyles}
data-testid={`${
<div css={controlContainerStyles(controlsPosition)}>
{AudioIcon && (
<button
type="button"
onClick={handleAudioClick}
css={buttonStyles}
data-link-name={`gu-video-loop-${
isMuted ? 'unmute' : 'mute'
}-icon`}
}-${atomId}`}
>
<AudioIcon
size="xsmall"
theme={{
fill: palette('--video-audio-icon'),
}}
/>
</div>
</button>
)}
<div
css={iconContainerStyles}
data-testid={`${
isMuted ? 'unmute' : 'mute'
}-icon`}
>
<AudioIcon
size="xsmall"
theme={{
fill: palette('--video-icon'),
}}
/>
</div>
</button>
)}
{showFullscreenIcon && (
<button
type="button"
onClick={handleFullscreenClick}
css={buttonStyles}
data-link-name={`gu-video-loop-fullscreen-${atomId}`}
>
<div
css={iconContainerStyles}
data-testid="fullscreen-icon"
>
<SvgArrowExpand
size="xsmall"
theme={{
fill: palette('--video-icon'),
}}
/>
</div>
</button>
)}
</div>
</>
)}
</>
Expand Down
14 changes: 7 additions & 7 deletions dotcom-rendering/src/paletteDeclarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading