From eb98e5ebc7b86b22f8bf8fb756714e669d583b4a Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Mon, 20 Apr 2026 11:51:45 +0100 Subject: [PATCH 1/4] Use a new interactive progress bar for long-form (default) video --- .../src/components/SelfHostedVideo.island.tsx | 29 ++- .../src/components/SelfHostedVideoPlayer.tsx | 57 +++-- .../src/components/SubtitleOverlay.tsx | 19 +- .../src/components/VideoProgressBar.tsx | 9 +- .../VideoProgressBarInteractive.tsx | 213 ++++++++++++++++++ dotcom-rendering/src/lib/video.test.ts | 47 ++++ dotcom-rendering/src/lib/video.ts | 20 ++ dotcom-rendering/src/paletteDeclarations.ts | 8 + 8 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 dotcom-rendering/src/components/VideoProgressBarInteractive.tsx diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 29ad22d545d..6d6f5c639b0 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -786,7 +786,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 +796,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,7 +811,7 @@ export const SelfHostedVideo = ({ } }; - const handleKeyDown = ( + const handleKeyDownVideo = ( event: React.KeyboardEvent, ): void => { if (isCinemagraph) return; @@ -837,6 +837,24 @@ export const SelfHostedVideo = ({ } }; + const handleKeyDownProgressBar = ( + event: React.KeyboardEvent, + ): void => { + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + playPauseVideo(); + break; + case 'ArrowRight': + seekForward(); + break; + case 'ArrowLeft': + seekBackward(); + break; + } + }; + /** * Autoplay/resume playback when the player comes into view or when * the page has been restored from the BFCache. @@ -944,9 +962,12 @@ export const SelfHostedVideo = ({ handlePlayPauseClick={handlePlayPauseClick} handleAudioClick={handleAudioClick} handleTimeUpdate={handleTimeUpdate} - handleKeyDown={handleKeyDown} + handleKeyDownVideo={handleKeyDownVideo} + useLongFormProgressBar={isDefault} + handleKeyDownProgressBar={handleKeyDownProgressBar} handlePause={handlePause} handleFullscreenClick={handleFullscreenClick} + updateCurrentTime={updateCurrentTime} onError={onError} AudioIcon={hasAudio ? AudioIcon : null} preloadPartialData={!!shouldAutoplay} diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 6518036938a..98b67b74b49 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -19,6 +19,7 @@ import { } from './SelfHostedVideoPlayerIcons'; 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 +70,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 +117,15 @@ export type Props = { handlePlaying: (event: SyntheticEvent) => void; handlePlayPauseClick: (event: SyntheticEvent) => void; handleAudioClick: (event: SyntheticEvent) => void; - handleKeyDown: (event: React.KeyboardEvent) => void; + handleKeyDownVideo: (event: React.KeyboardEvent) => void; handleTimeUpdate: (event: SyntheticEvent) => void; + handleKeyDownProgressBar: ( + event: React.KeyboardEvent, + ) => 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'; @@ -164,10 +174,13 @@ export const SelfHostedVideoPlayer = forwardRef( handlePlaying, handlePlayPauseClick, handleAudioClick, - handleKeyDown, + handleKeyDownVideo, handleTimeUpdate, + handleKeyDownProgressBar, + useLongFormProgressBar, handlePause, handleFullscreenClick, + updateCurrentTime, onError, AudioIcon, iconSize, @@ -229,7 +242,7 @@ export const SelfHostedVideoPlayer = forwardRef( onTimeUpdate={handleTimeUpdate} onPause={handlePause} onClick={handlePlayPauseClick} - onKeyDown={handleKeyDown} + onKeyDown={handleKeyDownVideo} onError={onError} > {sources.map(({ src, mimeType }) => ( @@ -255,7 +268,11 @@ export const SelfHostedVideoPlayer = forwardRef( )} {showPlayIcon && ( @@ -269,18 +286,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..5df283fc775 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 + */ + | 'raised-bottom'; + +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 === 'raised-bottom' && `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..8c1076d178b --- /dev/null +++ b/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx @@ -0,0 +1,213 @@ +import { css } from '@emotion/react'; +import { + focusHalo, + palette as sourcePalette, + textSans12, +} from '@guardian/source/foundations'; +import { getZIndex } from '../lib/getZIndex'; +import { + convertCurrentTimeToProgressPercentage, + convertProgressPercentageToCurrentTime, +} 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 formatTime = (timeInSeconds: number) => { + const clampedTimeInSeconds = Math.max(0, timeInSeconds); + + const minutes = Math.floor(clampedTimeInSeconds / 60); + const seconds = Math.floor(clampedTimeInSeconds % 60); + + if (isNaN(minutes) || isNaN(seconds)) { + return null; + } + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +const Time = ({ current, duration }: { current: number; duration: number }) => { + const right = formatTime(duration); + const left = formatTime(Math.min(current, duration)); + + if (right === null || left === null) { + return null; + } + + return ( + + ); +}; diff --git a/dotcom-rendering/src/lib/video.test.ts b/dotcom-rendering/src/lib/video.test.ts index 588f9a6ea1d..67f3495ee8b 100644 --- a/dotcom-rendering/src/lib/video.test.ts +++ b/dotcom-rendering/src/lib/video.test.ts @@ -1,7 +1,9 @@ import type { FEMediaAsset } from '../frontend/feFront'; import type { VideoAssets } from '../types/content'; import { + convertCurrentTimeToProgressPercentage, convertFEMediaAssetsToVideoAssets, + convertProgressPercentageToCurrentTime, extractValidSourcesFromAssets, findOptimisedSourcePerMimeType, getAspectRatioFromSources, @@ -263,4 +265,49 @@ 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: -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); + }, + ); + }); }); diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index db0b8114d55..53ed3d3ad68 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -141,3 +141,23 @@ 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; +}; 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], From 87aa73acf71181178a2b9440210aa6179301bde6 Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Wed, 22 Apr 2026 10:59:44 +0100 Subject: [PATCH 2/4] Combine key handlers --- .../src/components/SelfHostedVideo.island.tsx | 25 ++----------------- .../src/components/SelfHostedVideoPlayer.tsx | 12 +++------ 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 6d6f5c639b0..c29805761bb 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -811,9 +811,7 @@ export const SelfHostedVideo = ({ } }; - const handleKeyDownVideo = ( - event: React.KeyboardEvent, - ): void => { + const handleKeyDown = (event: React.KeyboardEvent): void => { if (isCinemagraph) return; switch (event.key) { @@ -837,24 +835,6 @@ export const SelfHostedVideo = ({ } }; - const handleKeyDownProgressBar = ( - event: React.KeyboardEvent, - ): void => { - switch (event.key) { - case 'Enter': - case ' ': - event.preventDefault(); - playPauseVideo(); - break; - case 'ArrowRight': - seekForward(); - break; - case 'ArrowLeft': - seekBackward(); - break; - } - }; - /** * Autoplay/resume playback when the player comes into view or when * the page has been restored from the BFCache. @@ -962,9 +942,8 @@ export const SelfHostedVideo = ({ handlePlayPauseClick={handlePlayPauseClick} handleAudioClick={handleAudioClick} handleTimeUpdate={handleTimeUpdate} - handleKeyDownVideo={handleKeyDownVideo} + handleKeyDown={handleKeyDown} useLongFormProgressBar={isDefault} - handleKeyDownProgressBar={handleKeyDownProgressBar} handlePause={handlePause} handleFullscreenClick={handleFullscreenClick} updateCurrentTime={updateCurrentTime} diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 98b67b74b49..12208c0447f 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -117,11 +117,8 @@ export type Props = { handlePlaying: (event: SyntheticEvent) => void; handlePlayPauseClick: (event: SyntheticEvent) => void; handleAudioClick: (event: SyntheticEvent) => void; - handleKeyDownVideo: (event: React.KeyboardEvent) => void; + handleKeyDown: (event: React.KeyboardEvent) => void; handleTimeUpdate: (event: SyntheticEvent) => void; - handleKeyDownProgressBar: ( - event: React.KeyboardEvent, - ) => void; useLongFormProgressBar: boolean; handlePause: (event: SyntheticEvent) => void; handleFullscreenClick?: (event: SyntheticEvent) => void; @@ -174,9 +171,8 @@ export const SelfHostedVideoPlayer = forwardRef( handlePlaying, handlePlayPauseClick, handleAudioClick, - handleKeyDownVideo, + handleKeyDown, handleTimeUpdate, - handleKeyDownProgressBar, useLongFormProgressBar, handlePause, handleFullscreenClick, @@ -242,7 +238,7 @@ export const SelfHostedVideoPlayer = forwardRef( onTimeUpdate={handleTimeUpdate} onPause={handlePause} onClick={handlePlayPauseClick} - onKeyDown={handleKeyDownVideo} + onKeyDown={handleKeyDown} onError={onError} > {sources.map(({ src, mimeType }) => ( @@ -293,7 +289,7 @@ export const SelfHostedVideoPlayer = forwardRef( currentTime={currentTime} updateCurrentTime={updateCurrentTime} duration={ref.current!.duration} - handleKeyDown={handleKeyDownProgressBar} + handleKeyDown={handleKeyDown} /> ) : ( Date: Wed, 22 Apr 2026 12:34:11 +0100 Subject: [PATCH 3/4] Add tests for formatting time in video player --- .../VideoProgressBarInteractive.tsx | 22 +++--------------- dotcom-rendering/src/lib/video.test.ts | 23 +++++++++++++++++++ dotcom-rendering/src/lib/video.ts | 9 ++++++++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx b/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx index 8c1076d178b..35e003cd63c 100644 --- a/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx +++ b/dotcom-rendering/src/components/VideoProgressBarInteractive.tsx @@ -8,6 +8,7 @@ import { getZIndex } from '../lib/getZIndex'; import { convertCurrentTimeToProgressPercentage, convertProgressPercentageToCurrentTime, + formatTimeForDisplay, } from '../lib/video'; import { palette } from '../palette'; @@ -184,26 +185,9 @@ const timeStyles = css` margin-left: 1px; /* To make it _feel_ more aligned with the progress bar, which has a border radius. */ `; -const formatTime = (timeInSeconds: number) => { - const clampedTimeInSeconds = Math.max(0, timeInSeconds); - - const minutes = Math.floor(clampedTimeInSeconds / 60); - const seconds = Math.floor(clampedTimeInSeconds % 60); - - if (isNaN(minutes) || isNaN(seconds)) { - return null; - } - - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -}; - const Time = ({ current, duration }: { current: number; duration: number }) => { - const right = formatTime(duration); - const left = formatTime(Math.min(current, duration)); - - if (right === null || left === null) { - return null; - } + const right = formatTimeForDisplay(duration); + const left = formatTimeForDisplay(Math.min(current, duration)); return (