Skip to content

Commit 9a02920

Browse files
abeddow91domlander
andauthored
Self styled subtitles (#14790)
* Use subtitles file and use in looping video * WIP - Trying to change the line cues are rendered on * Update hardcoded assets for testing m3u8, mp4 and vtt. This needs to be removed before merge * Remove background colour as this does not affect the existing cue box drawn by the browser. It instead draws another box around the cue box which is not desired. * Require CORS without credentials using "anonymous". This ensure the subtitles are available to JS on all browsers. * Measure the height of the video and set the absolute positioning of the cues by % off of that. * Remove background colour palette for subtitles as the background colour cannot currently be modified whilst we only support the browser subtitle implementation. * Remove hardcoded testing properties * Add a useSubtitles hook to allow players to customise subtitles * Add a useeffect to extra the track from the video element. * Store active track so we can extract cues * Add a listener for cue changes and update an activeCue state to keep track of the current cue. This is exposed by the hook for rendering in an overlay * Use subtitles file and use in looping video * WIP - Trying to change the line cues are rendered on * Update hardcoded assets for testing m3u8, mp4 and vtt. This needs to be removed before merge * Remove background colour as this does not affect the existing cue box drawn by the browser. It instead draws another box around the cue box which is not desired. * Require CORS without credentials using "anonymous". This ensure the subtitles are available to JS on all browsers. * Measure the height of the video and set the absolute positioning of the cues by % off of that. * Remove background colour palette for subtitles as the background colour cannot currently be modified whilst we only support the browser subtitle implementation. * Remove hardcoded testing properties * Reduce space from bottom to 12px on browser cues * Alias vidRef.current to video for easier parsing * Draw on subtitles * Move subtitles into its own component * If track is not available or we the video has not been started, reset active cues to null * Tidy up comments * Adjust overlay styling * Prefer keeping trackmode as showing so that ios is more reliable * Remove height protection as we should always be able to retrieve getBoundingClientRect * Add comment to support future development of the loop video player * Test hls fix with polling * skip failing test for testing * Hide cues by default * Remove need for polling by running onCueChange ahead of listener to kickstart iOS. * Rename subtitles -> activeCue so its clearer that this is seperate from the subtitleSource * Tidy up comment * Use source where possible * revert skipped test * Tidy up comments * Temporarily remove looping video stories to unblock PR. * fix linting * Remove unnecessary english=label --------- Co-authored-by: Dominik Lander <[email protected]>
1 parent ed3c08c commit 9a02920

File tree

5 files changed

+206
-4
lines changed

5 files changed

+206
-4
lines changed

dotcom-rendering/src/components/LoopVideo.importable.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getZIndex } from '../lib/getZIndex';
1212
import { generateImageURL } from '../lib/image';
1313
import { useIsInView } from '../lib/useIsInView';
1414
import { useShouldAdapt } from '../lib/useShouldAdapt';
15+
import { useSubtitles } from '../lib/useSubtitles';
1516
import type { CustomPlayEventDetail, Source } from '../lib/video';
1617
import {
1718
customLoopPlayAudioEventName,
@@ -177,6 +178,12 @@ export const LoopVideo = ({
177178
threshold: VISIBILITY_THRESHOLD,
178179
});
179180

181+
const activeCue = useSubtitles({
182+
video: vidRef.current,
183+
playerState,
184+
currentTime,
185+
});
186+
180187
const playVideo = useCallback(async () => {
181188
const video = vidRef.current;
182189
if (!video) return;
@@ -669,6 +676,7 @@ export const LoopVideo = ({
669676
showPlayIcon={showPlayIcon}
670677
subtitleSource={subtitleSource}
671678
subtitleSize={subtitleSize}
679+
activeCue={activeCue}
672680
enableLoopVideoCORS={enableLoopVideoCORS}
673681
/>
674682
</figure>

dotcom-rendering/src/components/LoopVideoPlayer.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import type {
1313
SyntheticEvent,
1414
} from 'react';
1515
import { forwardRef } from 'react';
16+
import type { ActiveCue } from '../lib/useSubtitles';
1617
import type { Source } from '../lib/video';
1718
import { palette } from '../palette';
1819
import { narrowPlayIconWidth, PlayIcon } from './Card/components/PlayIcon';
1920
import { LoopVideoProgressBar } from './LoopVideoProgressBar';
21+
import { SubtitleOverlay } from './SubtitleOverlay';
2022

2123
export type SubtitleSize = 'small' | 'medium' | 'large';
2224

@@ -35,8 +37,10 @@ const videoStyles = (
3537
object-fit: cover;
3638
3739
::cue {
38-
color: ${palette('--loop-video-subtitle-text')};
40+
/* Hide the cue by default as we prefer custom overlay */
41+
visibility: hidden;
3942
43+
color: ${palette('--loop-video-subtitle-text')};
4044
${subtitleSize === 'small' && textSans15};
4145
${subtitleSize === 'medium' && textSans17};
4246
${subtitleSize === 'large' && textSans20};
@@ -119,6 +123,8 @@ type Props = {
119123
showPlayIcon: boolean;
120124
subtitleSource?: string;
121125
subtitleSize: SubtitleSize;
126+
/* used in custom subtitle overlays */
127+
activeCue?: ActiveCue | null;
122128
/** Feature flag for the enabling CORS loading on looping video */
123129
enableLoopVideoCORS: boolean;
124130
};
@@ -160,12 +166,12 @@ export const LoopVideoPlayer = forwardRef(
160166
showPlayIcon,
161167
subtitleSource,
162168
subtitleSize,
169+
activeCue,
163170
enableLoopVideoCORS,
164171
}: Props,
165172
ref: React.ForwardedRef<HTMLVideoElement>,
166173
) => {
167174
const loopVideoId = `loop-video-${uniqueId}`;
168-
169175
return (
170176
<>
171177
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- Captions will be considered later. */}
@@ -176,7 +182,6 @@ export const LoopVideoPlayer = forwardRef(
176182
? { crossOrigin: 'anonymous' }
177183
: {})}
178184
ref={ref}
179-
crossOrigin="anonymous"
180185
tabIndex={0}
181186
data-testid="loop-video"
182187
height={height}
@@ -222,13 +227,21 @@ export const LoopVideoPlayer = forwardRef(
222227
))}
223228
{subtitleSource !== undefined && (
224229
<track
225-
default={true}
230+
// Don't use default - it forces native rendering on iOS
231+
default={false}
226232
kind="subtitles"
227233
src={subtitleSource}
234+
srcLang="en"
228235
/>
229236
)}
230237
{FallbackImageComponent}
231238
</video>
239+
{!!activeCue?.text && (
240+
<SubtitleOverlay
241+
text={activeCue.text}
242+
subtitleSize={subtitleSize}
243+
/>
244+
)}
232245
{ref && 'current' in ref && ref.current && isPlayable && (
233246
<>
234247
{/* Play icon */}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { css } from '@emotion/react';
2+
import {
3+
space,
4+
textSans15,
5+
textSans17,
6+
textSans20,
7+
} from '@guardian/source/foundations';
8+
import { palette } from '../palette';
9+
import type { SubtitleSize } from './LoopVideoPlayer';
10+
11+
const subtitleOverlayStyles = css`
12+
max-width: 71%;
13+
pointer-events: none;
14+
position: absolute;
15+
bottom: ${space[4]}px;
16+
left: 50%;
17+
transform: translateX(-50%);
18+
`;
19+
20+
const cueBoxStyles = css`
21+
width: 100%;
22+
margin: 0 auto;
23+
text-align: center;
24+
pointer-events: none;
25+
`;
26+
27+
const cueStyles = css`
28+
color: ${palette('--loop-video-subtitle-text')};
29+
display: inline;
30+
background-color: ${palette('--loop-video-subtitle-background')};
31+
-webkit-box-decoration-break: clone;
32+
box-decoration-break: clone;
33+
pointer-events: none;
34+
padding: 3px ${space[1]}px 3px;
35+
`;
36+
37+
const cueTextStyles = (subtitleSize: SubtitleSize) => {
38+
const sizeStyles = {
39+
small: css`
40+
${textSans15};
41+
line-height: ${space[6]}px;
42+
`,
43+
medium: css`
44+
${textSans17};
45+
line-height: 26px;
46+
`,
47+
large: css`
48+
${textSans20};
49+
line-height: 30px;
50+
`,
51+
};
52+
53+
return sizeStyles[subtitleSize];
54+
};
55+
56+
export const SubtitleOverlay = ({
57+
text,
58+
subtitleSize,
59+
}: {
60+
text: string;
61+
subtitleSize: SubtitleSize;
62+
}) => {
63+
return (
64+
<div css={subtitleOverlayStyles}>
65+
<div css={cueBoxStyles}>
66+
<div css={[cueStyles, cueTextStyles(subtitleSize)]}>{text}</div>
67+
</div>
68+
</div>
69+
);
70+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useEffect, useState } from 'react';
2+
import type { PlayerStates } from '../components/LoopVideoPlayer';
3+
4+
type Props = {
5+
video: HTMLVideoElement | null;
6+
playerState: PlayerStates;
7+
currentTime: number;
8+
};
9+
10+
export type ActiveCue = {
11+
startTime: number;
12+
endTime: number;
13+
text: string;
14+
};
15+
16+
export const useSubtitles = ({
17+
video,
18+
playerState,
19+
currentTime,
20+
}: Props): ActiveCue | null => {
21+
const [activeTrack, setActiveTrack] = useState<TextTrack | null>(null);
22+
const [activeCue, setActiveCue] = useState<ActiveCue | null>(null);
23+
24+
/* Only show subtitles if the video is actively playing or if it's paused. */
25+
const shouldShow = playerState === 'PLAYING' || currentTime > 0;
26+
27+
useEffect(() => {
28+
if (!video) return;
29+
30+
const textTracks = video.textTracks;
31+
32+
const setTrackFromList = () => {
33+
const track = textTracks[0];
34+
/* We currently only support one text track per video, so we are OK to access [0] here. */
35+
if (!track) return;
36+
37+
/* Keep track in 'showing' mode for iOS reliability.
38+
* We'll hide the native subtitles with CSS instead
39+
*/
40+
if (track.mode !== 'showing') {
41+
track.mode = 'showing';
42+
}
43+
44+
setActiveTrack(track);
45+
};
46+
47+
/* Get Text track as soon as the video element is available */
48+
setTrackFromList();
49+
50+
/* Listen for delayed loads across all scenarios */
51+
textTracks.addEventListener('addtrack', setTrackFromList);
52+
video.addEventListener('loadedmetadata', setTrackFromList);
53+
video.addEventListener('loadeddata', setTrackFromList);
54+
video.addEventListener('canplay', setTrackFromList);
55+
56+
return () => {
57+
textTracks.removeEventListener('addtrack', setTrackFromList);
58+
video.removeEventListener('loadedmetadata', setTrackFromList);
59+
video.removeEventListener('loadeddata', setTrackFromList);
60+
video.removeEventListener('canplay', setTrackFromList);
61+
};
62+
}, [video]);
63+
64+
useEffect(() => {
65+
const track = activeTrack;
66+
67+
if (!track || !shouldShow) {
68+
setActiveCue(null);
69+
return;
70+
}
71+
72+
/* Keep track in 'showing' mode.
73+
* this makes iOS more reliable with cuechange events and activeCues
74+
*/
75+
if (track.mode !== 'showing') {
76+
track.mode = 'showing';
77+
}
78+
79+
/* listen to cuechange and set the active cue */
80+
const onCueChange = () => {
81+
const list = track.activeCues;
82+
if (!list || list.length === 0) {
83+
setActiveCue(null);
84+
return;
85+
}
86+
const cue = list[0] as VTTCue;
87+
setActiveCue({
88+
startTime: cue.startTime,
89+
endTime: cue.endTime,
90+
text: cue.text,
91+
});
92+
};
93+
94+
track.addEventListener('cuechange', onCueChange);
95+
96+
/* Initial check */
97+
onCueChange();
98+
99+
return () => {
100+
track.removeEventListener('cuechange', onCueChange);
101+
/* Keep it showing even on cleanup for consistency */
102+
track.mode = 'showing';
103+
};
104+
}, [activeTrack, shouldShow, currentTime]);
105+
106+
return activeCue;
107+
};

dotcom-rendering/src/paletteDeclarations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7335,6 +7335,10 @@ const paletteColours = {
73357335
light: () => sourcePalette.neutral[86],
73367336
dark: () => sourcePalette.neutral[86],
73377337
},
7338+
'--loop-video-subtitle-background': {
7339+
light: () => transparentColour(sourcePalette.neutral[7], 0.7),
7340+
dark: () => transparentColour(sourcePalette.neutral[7], 0.7),
7341+
},
73387342
'--loop-video-subtitle-text': {
73397343
light: () => sourcePalette.neutral[100],
73407344
dark: () => sourcePalette.neutral[100],

0 commit comments

Comments
 (0)