11import { css } from '@emotion/react' ;
22import { log , storage } from '@guardian/libs' ;
3+ import { space } from '@guardian/source/foundations' ;
34import { SvgAudio , SvgAudioMute } from '@guardian/source/react-components' ;
45import { useCallback , useEffect , useRef , useState } from 'react' ;
56import {
@@ -11,15 +12,20 @@ import { getZIndex } from '../lib/getZIndex';
1112import { generateImageURL } from '../lib/image' ;
1213import { useIsInView } from '../lib/useIsInView' ;
1314import { useShouldAdapt } from '../lib/useShouldAdapt' ;
15+ import { useSubtitles } from '../lib/useSubtitles' ;
1416import type { CustomPlayEventDetail , Source } from '../lib/video' ;
1517import {
1618 customLoopPlayAudioEventName ,
1719 customYoutubePlayEventName ,
1820} from '../lib/video' ;
1921import { CardPicture , type Props as CardPictureProps } from './CardPicture' ;
2022import { useConfig } from './ConfigContext' ;
23+ import type {
24+ PLAYER_STATES ,
25+ PlayerStates ,
26+ SubtitleSize ,
27+ } from './LoopVideoPlayer' ;
2128import { LoopVideoPlayer } from './LoopVideoPlayer' ;
22- import type { PLAYER_STATES , PlayerStates } from './LoopVideoPlayer' ;
2329import { ophanTrackerWeb } from './YoutubeAtom/eventEmitters' ;
2430
2531const videoContainerStyles = css `
@@ -117,6 +123,8 @@ type Props = {
117123 fallbackImageAlt : CardPictureProps [ 'alt' ] ;
118124 fallbackImageAspectRatio : CardPictureProps [ 'aspectRatio' ] ;
119125 linkTo : string ;
126+ subtitleSource ?: string ;
127+ subtitleSize : SubtitleSize ;
120128} ;
121129
122130export const LoopVideo = ( {
@@ -132,6 +140,8 @@ export const LoopVideo = ({
132140 fallbackImageAlt,
133141 fallbackImageAspectRatio,
134142 linkTo,
143+ subtitleSource,
144+ subtitleSize,
135145} : Props ) => {
136146 const adapted = useShouldAdapt ( ) ;
137147 const { renderingTarget } = useConfig ( ) ;
@@ -155,6 +165,8 @@ export const LoopVideo = ({
155165 * want to pause the video if it has been in view.
156166 */
157167 const [ hasBeenInView , setHasBeenInView ] = useState ( false ) ;
168+ const [ hasBeenPlayed , setHasBeenPlayed ] = useState ( false ) ;
169+ const [ hasTrackedPlay , setHasTrackedPlay ] = useState ( false ) ;
158170
159171 const [ devicePixelRatio , setDevicePixelRatio ] = useState ( 1 ) ;
160172
@@ -165,6 +177,12 @@ export const LoopVideo = ({
165177 threshold : VISIBILITY_THRESHOLD ,
166178 } ) ;
167179
180+ const activeCue = useSubtitles ( {
181+ video : vidRef . current ,
182+ playerState,
183+ currentTime,
184+ } ) ;
185+
168186 const playVideo = useCallback ( async ( ) => {
169187 const video = vidRef . current ;
170188 if ( ! video ) return ;
@@ -178,6 +196,7 @@ export const LoopVideo = ({
178196 . then ( ( ) => {
179197 // Autoplay succeeded
180198 dispatchOphanAttentionEvent ( 'videoPlaying' ) ;
199+ setHasBeenPlayed ( true ) ;
181200 setPlayerState ( 'PLAYING' ) ;
182201 } )
183202 . catch ( ( error : Error ) => {
@@ -387,6 +406,19 @@ export const LoopVideo = ({
387406 }
388407 } , [ isInView , hasBeenInView , atomId , linkTo ] ) ;
389408
409+ /**
410+ * Track the first successful video play in Ophan.
411+ *
412+ * This effect runs only after the video has actually started playing
413+ * for the first time. This is to ensure we don't double-report the event.
414+ */
415+ useEffect ( ( ) => {
416+ if ( ! hasBeenPlayed || hasTrackedPlay ) return ;
417+
418+ ophanTrackerWeb ( atomId , 'loop' ) ( 'play' ) ;
419+ setHasTrackedPlay ( true ) ;
420+ } , [ atomId , hasBeenPlayed , hasTrackedPlay ] ) ;
421+
390422 /**
391423 * Handle play/pause, when instigated by the browser.
392424 */
@@ -416,14 +448,6 @@ export const LoopVideo = ({
416448 playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' ||
417449 ( hasPageBecomeActive && playerState === 'PAUSED_BY_BROWSER' ) )
418450 ) {
419- /**
420- * Check if the video has not been in view before tracking the play.
421- * This is so we only track the first play.
422- */
423- if ( ! hasBeenInView ) {
424- ophanTrackerWeb ( atomId , 'loop' ) ( 'play' ) ;
425- }
426-
427451 setHasPageBecomeActive ( false ) ;
428452 void playVideo ( ) ;
429453 }
@@ -482,6 +506,25 @@ export const LoopVideo = ({
482506 return FallbackImageComponent ;
483507 }
484508
509+ const handleLoadedMetadata = ( ) => {
510+ const video = vidRef . current ;
511+ if ( ! video ) return ;
512+
513+ const track = video . textTracks [ 0 ] ;
514+ if ( ! track ?. cues ) return ;
515+ const pxFromBottom = space [ 3 ] ;
516+ const videoHeight = video . getBoundingClientRect ( ) . height ;
517+ const percentFromTop =
518+ ( ( videoHeight - pxFromBottom ) / videoHeight ) * 100 ;
519+
520+ for ( const cue of Array . from ( track . cues ) ) {
521+ if ( cue instanceof VTTCue ) {
522+ cue . snapToLines = false ;
523+ cue . line = percentFromTop ;
524+ }
525+ }
526+ } ;
527+
485528 const handleLoadedData = ( ) => {
486529 if ( vidRef . current ) {
487530 setHasAudio ( doesVideoHaveAudio ( vidRef . current ) ) ;
@@ -621,6 +664,7 @@ export const LoopVideo = ({
621664 isPlayable = { isPlayable }
622665 playerState = { playerState }
623666 isMuted = { isMuted }
667+ handleLoadedMetadata = { handleLoadedMetadata }
624668 handleLoadedData = { handleLoadedData }
625669 handleCanPlay = { handleCanPlay }
626670 handlePlayPauseClick = { handlePlayPauseClick }
@@ -631,6 +675,9 @@ export const LoopVideo = ({
631675 AudioIcon = { hasAudio ? AudioIcon : null }
632676 preloadPartialData = { preloadPartialData }
633677 showPlayIcon = { showPlayIcon }
678+ subtitleSource = { subtitleSource }
679+ subtitleSize = { subtitleSize }
680+ activeCue = { activeCue }
634681 />
635682 </ figure >
636683 ) ;
0 commit comments