diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 29ad22d545d..f613b4778cb 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -36,6 +36,7 @@ import type { SubtitleSize, } from './SelfHostedVideoPlayer'; import { SelfHostedVideoPlayer } from './SelfHostedVideoPlayer'; +import type { SubtitlesPosition } from './SubtitleOverlay'; import type { OphanVideoStyle } from './YoutubeAtom/eventEmitters'; import { ophanTrackerApps, ophanTrackerWeb } from './YoutubeAtom/eventEmitters'; @@ -384,6 +385,13 @@ export const SelfHostedVideo = ({ const iconSize = isDefault ? 'large' : 'small'; + const useLongFormProgressBar = isDefault; + + const subtitlesPosition: SubtitlesPosition = + useLongFormProgressBar && controlsPosition === 'bottom' + ? 'bottom-elevated' + : controlsPosition; + const ophanVideoStyle = videoStyle.toLowerCase() as OphanVideoStyle; const [isInView, setNode] = useIsInView({ @@ -786,7 +794,7 @@ export const SelfHostedVideo = ({ const video = vidRef.current; if (!video) return; - const increment = 1; + const increment = isDefault ? 10 : 1; const newTime = Math.min(video.currentTime + increment, video.duration); updateCurrentTime(newTime); @@ -796,7 +804,7 @@ export const SelfHostedVideo = ({ const video = vidRef.current; if (!video) return; - const increment = 1; + const increment = isDefault ? 10 : 1; const newTime = Math.max(video.currentTime - increment, 0); updateCurrentTime(newTime); @@ -811,9 +819,7 @@ export const SelfHostedVideo = ({ } }; - const handleKeyDown = ( - event: React.KeyboardEvent, - ): void => { + const handleKeyDown = (event: React.KeyboardEvent): void => { if (isCinemagraph) return; switch (event.key) { @@ -945,8 +951,10 @@ export const SelfHostedVideo = ({ handleAudioClick={handleAudioClick} handleTimeUpdate={handleTimeUpdate} handleKeyDown={handleKeyDown} + useLongFormProgressBar={useLongFormProgressBar} handlePause={handlePause} handleFullscreenClick={handleFullscreenClick} + updateCurrentTime={updateCurrentTime} onError={onError} AudioIcon={hasAudio ? AudioIcon : null} preloadPartialData={!!shouldAutoplay} @@ -958,6 +966,7 @@ export const SelfHostedVideo = ({ subtitleSize={subtitleSize} showIcons={showIcons} controlsPosition={controlsPosition} + subtitlesPosition={subtitlesPosition} activeCue={activeCue} shouldLoop={shouldLoop} showFullscreenIcon={isDefault} diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 6518036938a..c7902a2bb4e 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -17,8 +17,10 @@ import { AudioIcon as AudioIconComponent, FullscreenIcon, } from './SelfHostedVideoPlayerIcons'; +import type { SubtitlesPosition } from './SubtitleOverlay'; import { SubtitleOverlay } from './SubtitleOverlay'; import { VideoProgressBar } from './VideoProgressBar'; +import { VideoProgressBarInteractive } from './VideoProgressBarInteractive'; export type SubtitleSize = 'small' | 'medium' | 'large'; export type ControlsPosition = 'top' | 'bottom'; @@ -69,12 +71,16 @@ const iconsContainerStyles = css` right: ${space[2]}px; `; -const iconsPositionStyles = (position: ControlsPosition) => css` - /* Take into account the progress bar height */ +const smallIconsPositionStyles = (position: ControlsPosition) => css` ${position === 'bottom' && `bottom: ${space[3]}px;`} ${position === 'top' && `top: ${space[2]}px;`} `; +const largeIconsPositionStyles = (position: ControlsPosition) => css` + ${position === 'bottom' && `bottom: ${space[12]}px;`} + ${position === 'top' && `top: ${space[2]}px;`} +`; + export const PLAYER_STATES = [ 'NOT_STARTED', 'PLAYING', @@ -112,10 +118,12 @@ export type Props = { handlePlaying: (event: SyntheticEvent) => void; handlePlayPauseClick: (event: SyntheticEvent) => void; handleAudioClick: (event: SyntheticEvent) => void; - handleKeyDown: (event: React.KeyboardEvent) => void; + handleKeyDown: (event: React.KeyboardEvent) => void; handleTimeUpdate: (event: SyntheticEvent) => void; + useLongFormProgressBar: boolean; handlePause: (event: SyntheticEvent) => void; handleFullscreenClick?: (event: SyntheticEvent) => void; + updateCurrentTime: (time: number) => void; onError: (event: SyntheticEvent) => void; AudioIcon: ((iconProps: IconProps) => JSX.Element) | null; iconSize: 'small' | 'large'; @@ -133,6 +141,7 @@ export type Props = { shouldLoop: boolean; isInteractive: boolean; controlsPosition: ControlsPosition; + subtitlesPosition: SubtitlesPosition; }; /** @@ -166,8 +175,10 @@ export const SelfHostedVideoPlayer = forwardRef( handleAudioClick, handleKeyDown, handleTimeUpdate, + useLongFormProgressBar, handlePause, handleFullscreenClick, + updateCurrentTime, onError, AudioIcon, iconSize, @@ -183,6 +194,7 @@ export const SelfHostedVideoPlayer = forwardRef( shouldLoop, isInteractive, controlsPosition, + subtitlesPosition, }: Props, ref: React.ForwardedRef, ) => { @@ -255,7 +267,7 @@ export const SelfHostedVideoPlayer = forwardRef( )} {showPlayIcon && ( @@ -269,18 +281,30 @@ export const SelfHostedVideoPlayer = forwardRef( )} - {showProgressBar && ( - - )} + {showProgressBar && + (useLongFormProgressBar ? ( + + ) : ( + + ))} {showIcons && (
{showFullscreenIcon && ( diff --git a/dotcom-rendering/src/components/SubtitleOverlay.tsx b/dotcom-rendering/src/components/SubtitleOverlay.tsx index 8a1f2309bbd..672fb4e5fb6 100644 --- a/dotcom-rendering/src/components/SubtitleOverlay.tsx +++ b/dotcom-rendering/src/components/SubtitleOverlay.tsx @@ -6,17 +6,28 @@ import { textSans20, } from '@guardian/source/foundations'; import { palette } from '../palette'; -import type { ControlsPosition, SubtitleSize } from './SelfHostedVideoPlayer'; +import type { SubtitleSize } from './SelfHostedVideoPlayer'; -const subtitleOverlayStyles = (position: ControlsPosition) => css` +export type SubtitlesPosition = + | 'top' + | 'bottom' + /** + * Subtitles are anchored to the bottom, but leave enough room for a tall progress bar + */ + | 'bottom-elevated'; + +const subtitleOverlayStyles = css` width: 100%; display: flex; justify-content: center; pointer-events: none; position: absolute; +`; +const subtitlePositionStyles = (position: SubtitlesPosition) => css` ${position === 'top' && `top: ${space[4]}px;`}; ${position === 'bottom' && `bottom: ${space[4]}px;`}; + ${position === 'bottom-elevated' && `bottom: ${space[12]}px;`}; `; const cueBoxStyles = css` @@ -62,10 +73,10 @@ export const SubtitleOverlay = ({ }: { text: string; size: SubtitleSize; - position: 'top' | 'bottom'; + position: SubtitlesPosition; }) => { return ( -
+
{text}
diff --git a/dotcom-rendering/src/components/VideoProgressBar.tsx b/dotcom-rendering/src/components/VideoProgressBar.tsx index 1c996f7b7aa..fbafef26c22 100644 --- a/dotcom-rendering/src/components/VideoProgressBar.tsx +++ b/dotcom-rendering/src/components/VideoProgressBar.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import { getZIndex } from '../lib/getZIndex'; +import { convertCurrentTimeToProgressPercentage } from '../lib/video'; import { palette } from '../palette'; const styles = css` @@ -49,11 +50,11 @@ export const VideoProgressBar = ({ videoId, currentTime, duration }: Props) => { */ const adjustedDuration = duration > 1 ? duration - 0.25 : duration; - const progressPercentage = Math.min( - (currentTime * 100) / adjustedDuration, - 100, + const progressPercentage = convertCurrentTimeToProgressPercentage( + currentTime, + adjustedDuration, ); - if (Number.isNaN(progressPercentage)) { + if (progressPercentage === null) { return null; } diff --git a/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx b/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx new file mode 100644 index 00000000000..35e003cd63c --- /dev/null +++ b/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx @@ -0,0 +1,197 @@ +import { css } from '@emotion/react'; +import { + focusHalo, + palette as sourcePalette, + textSans12, +} from '@guardian/source/foundations'; +import { getZIndex } from '../lib/getZIndex'; +import { + convertCurrentTimeToProgressPercentage, + convertProgressPercentageToCurrentTime, + formatTimeForDisplay, +} from '../lib/video'; +import { palette } from '../palette'; + +const containerStyles = css` + position: absolute; + bottom: 0; + left: 0; + height: 44px; + width: 100%; + z-index: ${getZIndex('video-progress-bar-background')}; + cursor: pointer; + padding: 0 12px; + background: linear-gradient( + to top, + ${sourcePalette.neutral[0]} 0%, + transparent 100% + ); +`; + +const trackStyles = css` + -webkit-appearance: none; + appearance: none; + height: 5px; + border-radius: 5px; +`; + +const thumbStyles = css` + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border: none; + border-radius: 50%; + background-color: ${palette('--video-progress-bar-interactive-value')}; + z-index: ${getZIndex('video-progress-bar-foreground')}; + cursor: pointer; +`; + +const progressBarStyles = (roundedProgressPercentage: number) => css` + width: 100%; + cursor: pointer; + height: 5px; + border-radius: 5px; + -webkit-appearance: none; /* Hides the slider so that custom slider can be made */ + appearance: none; + /* The colour to the left of the thumb is different to the right to indicate progress */ + background: ${`linear-gradient( + to right, + ${palette('--video-progress-bar-interactive-value')} 0%, + ${palette( + '--video-progress-bar-interactive-value', + )} ${roundedProgressPercentage}%, + ${palette( + '--video-progress-bar-interactive-background', + )} ${roundedProgressPercentage}%, + ${palette('--video-progress-bar-interactive-background')} 100% + )`}; + + /* We don't use the default focus ring as it includes the thumb, which looks odd. */ + :focus-visible { + ${focusHalo} + } + + /** Extend the clickable area of the progress bar (only works on Chrome) */ + ::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 36px; + } + + ::-webkit-slider-runnable-track { + ${trackStyles} + } + ::-moz-range-track { + ${trackStyles} + } + ::-ms-track { + ${trackStyles} + width: 100%; + } + + ::-webkit-slider-thumb { + -webkit-appearance: none; + margin-top: -5px; + ${thumbStyles} + } + ::-moz-range-thumb { + ${thumbStyles} + } + ::-ms-thumb { + ${thumbStyles} + } +`; + +const handleChange = ( + value: string, + duration: Props['duration'], + updateCurrentTime: Props['updateCurrentTime'], +) => { + const percentage = Number(value); + const time = convertProgressPercentageToCurrentTime(percentage, duration); + + if (time === null) return; + + updateCurrentTime(time); +}; + +type Props = { + videoId: string; + currentTime: number; + updateCurrentTime: (time: number) => void; + handleKeyDown: (event: React.KeyboardEvent) => void; + duration: number; +}; + +/** + * A progress bar for the self-hosted video component. + * + * Q. Why don't we use the element? + * A. It was not possible to properly style the native progress element in Safari. + */ +export const VideoProgressBarInteractive = ({ + videoId, + currentTime, + updateCurrentTime, + handleKeyDown, + duration, +}: Props) => { + if (duration <= 0) return null; + + const progressPercentage = convertCurrentTimeToProgressPercentage( + currentTime, + duration, + ); + if (progressPercentage === null) { + return null; + } + + const roundedProgressPercentage = Number(progressPercentage.toFixed(2)); + + return ( +
+
+ ); +}; + +const timeStyles = css` + ${textSans12}; + color: ${sourcePalette.neutral[100]}; + margin-left: 1px; /* To make it _feel_ more aligned with the progress bar, which has a border radius. */ +`; + +const Time = ({ current, duration }: { current: number; duration: number }) => { + const right = formatTimeForDisplay(duration); + const left = formatTimeForDisplay(Math.min(current, duration)); + + return ( + + ); +}; diff --git a/dotcom-rendering/src/lib/video.test.ts b/dotcom-rendering/src/lib/video.test.ts index 588f9a6ea1d..faef75a08e7 100644 --- a/dotcom-rendering/src/lib/video.test.ts +++ b/dotcom-rendering/src/lib/video.test.ts @@ -1,9 +1,12 @@ import type { FEMediaAsset } from '../frontend/feFront'; import type { VideoAssets } from '../types/content'; import { + convertCurrentTimeToProgressPercentage, convertFEMediaAssetsToVideoAssets, + convertProgressPercentageToCurrentTime, extractValidSourcesFromAssets, findOptimisedSourcePerMimeType, + formatTimeForDisplay, getAspectRatioFromSources, } from './video'; import type { Source } from './video'; @@ -263,4 +266,71 @@ describe('video', () => { expect(sources).toEqual([mp4Src720h, m3u8Src720h]); }); }); + + describe('convertCurrentTimeToProgressPercentage', () => { + it.each([ + { currentTime: 0, duration: 23, expectedPercentage: 0 }, + { currentTime: 24, duration: 32, expectedPercentage: 75 }, + { currentTime: 56, duration: 56, expectedPercentage: 100 }, + { currentTime: 12, duration: 11, expectedPercentage: 100 }, + { currentTime: -5, duration: 10, expectedPercentage: null }, + { currentTime: 5, duration: -10, expectedPercentage: null }, + ])( + 'should return the correct progress percentage based on the current time and duration', + ({ currentTime, duration, expectedPercentage }) => { + expect( + convertCurrentTimeToProgressPercentage( + currentTime, + duration, + ), + ).toEqual(expectedPercentage); + }, + ); + }); + + describe('convertProgressPercentageToCurrentTime', () => { + it.each([ + { progressPercentage: 0, duration: 23, expectedCurrentTime: 0 }, + { progressPercentage: 75, duration: 32, expectedCurrentTime: 24 }, + { progressPercentage: 100, duration: 56, expectedCurrentTime: 56 }, + { progressPercentage: 103, duration: 11, expectedCurrentTime: 11 }, + { progressPercentage: 10, duration: 0, expectedCurrentTime: null }, + { progressPercentage: 8, duration: -10, expectedCurrentTime: null }, + { + progressPercentage: -0.1244235, + duration: 10, + expectedCurrentTime: 0, + }, + ])( + 'should return the correct current time based on the progress percentage and duration', + ({ progressPercentage, duration, expectedCurrentTime }) => { + expect( + convertProgressPercentageToCurrentTime( + progressPercentage, + duration, + ), + ).toEqual(expectedCurrentTime); + }, + ); + }); + + describe('formatTimeForDisplay', () => { + it.each([ + { timeInSeconds: -1.24, expectedFormattedTime: '0:00' }, + { timeInSeconds: 0, expectedFormattedTime: '0:00' }, + { timeInSeconds: 59, expectedFormattedTime: '0:59' }, + { timeInSeconds: 60, expectedFormattedTime: '1:00' }, + { timeInSeconds: 61, expectedFormattedTime: '1:01' }, + { timeInSeconds: 92.5, expectedFormattedTime: '1:32' }, + { timeInSeconds: 1000, expectedFormattedTime: '16:40' }, + { timeInSeconds: 10000, expectedFormattedTime: '166:40' }, + ])( + 'should return the correct formatted time based on the time in seconds', + ({ timeInSeconds, expectedFormattedTime }) => { + expect(formatTimeForDisplay(timeInSeconds)).toEqual( + expectedFormattedTime, + ); + }, + ); + }); }); diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index db0b8114d55..7db70f47599 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -141,3 +141,32 @@ export const findOptimisedSourcePerMimeType = ( return acc; }, []); }; + +export const convertCurrentTimeToProgressPercentage = ( + currentTime: number, + duration: number, +): number | null => { + if (currentTime < 0 || duration <= 0) return null; + + return Math.min((currentTime * 100) / duration, 100); +}; + +export const convertProgressPercentageToCurrentTime = ( + progressPercentage: number, + duration: number, +): number | null => { + if (duration <= 0) return null; + + const clampedPercentage = Math.max(0, Math.min(progressPercentage, 100)); + + return (clampedPercentage / 100) * duration; +}; + +export const formatTimeForDisplay = (timeInSeconds: number): string => { + const clampedTimeInSeconds = Math.max(0, timeInSeconds); + + const minutes = Math.floor(clampedTimeInSeconds / 60); + const seconds = Math.floor(clampedTimeInSeconds % 60); + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index b692225f9f8..b1fb7e6becb 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -8429,6 +8429,14 @@ const paletteColours = { light: () => transparentColour(sourcePalette.neutral[7], 0.7), dark: () => transparentColour(sourcePalette.neutral[7], 0.7), }, + '--video-progress-bar-interactive-background': { + light: () => transparentColour(sourcePalette.neutral[100], 0.5), + dark: () => transparentColour(sourcePalette.neutral[100], 0.5), + }, + '--video-progress-bar-interactive-value': { + light: () => sourcePalette.neutral[100], + dark: () => sourcePalette.neutral[100], + }, '--video-progress-bar-value': { light: () => sourcePalette.neutral[86], dark: () => sourcePalette.neutral[86],