diff --git a/src/app/components/ATIAnalytics/atiUrl/index.client.test.ts b/src/app/components/ATIAnalytics/atiUrl/index.client.test.ts index bfba0cf5fce..b7096b21dd3 100644 --- a/src/app/components/ATIAnalytics/atiUrl/index.client.test.ts +++ b/src/app/components/ATIAnalytics/atiUrl/index.client.test.ts @@ -257,6 +257,40 @@ describe('atiUrl', () => { }); }); + it('should include skip-rate fields when provided on itemTracker', () => { + const componentSpecificTrack = buildReverbEventModel({ + ...input, + itemTracker: { + type: 'portrait-video', + text: 'Example title', + duration: 9000, + totalDuration: 12000, + completionRate: 0.75, + skipRate: 0.25, + navigationMethod: 'swipe', + sessionExitReason: 'navigation', + versionId: 'p1234567', + resourceId: 'urn:bbc:pips:pid:p1234567', + }, + }); + + expect(componentSpecificTrack.eventDetails.item).toEqual({ + attribution: 'advertiserID', + duration: 9000, + session_exit_reason: 'navigation', + link: 'http://localhost', + name: 'top-stories', + navigation_method: 'swipe', + resource_id: 'urn:bbc:pips:pid:p1234567', + skip_rate: 0.25, + text: 'Example title', + total_duration: 12000, + type: 'portrait-video', + completion_rate: 0.75, + version_id: 'p1234567', + }); + }); + it('should return the correct Reverb group event model', () => { const blockSpecificTrack = buildReverbEventModel({ ...input, diff --git a/src/app/components/ATIAnalytics/atiUrl/index.ts b/src/app/components/ATIAnalytics/atiUrl/index.ts index 180bb94c94a..246b967edb7 100644 --- a/src/app/components/ATIAnalytics/atiUrl/index.ts +++ b/src/app/components/ATIAnalytics/atiUrl/index.ts @@ -128,11 +128,18 @@ export const buildReverbEventModel = ({ groupTracker = {}, eventGroupingName, }: ATIEventTrackingProps): ReverbBeaconConfig => { + // these optional fields are emitted by the portrait video skip-rate spike const { type: itemType, text, position, duration, + totalDuration, + completionRate, + skipRate, + navigationMethod, + sessionExitReason, + versionId, label, mediaType, resourceId: itemResourceId, @@ -174,6 +181,15 @@ export const buildReverbEventModel = ({ ...(text && { text }), ...(position && { position }), ...(duration && { duration }), + // these are mapped to snake_case so the payload matches existing reverb conventions + ...(totalDuration && { total_duration: totalDuration }), + ...(completionRate != null && { completion_rate: completionRate }), + ...(skipRate != null && { skip_rate: skipRate }), + ...(navigationMethod && { navigation_method: navigationMethod }), + ...(sessionExitReason && { + session_exit_reason: sessionExitReason, + }), + ...(versionId && { version_id: versionId }), ...(mediaType && { media_type: mediaType }), ...(label && { label }), ...(itemResourceId && { resource_id: itemResourceId }), diff --git a/src/app/components/ATIAnalytics/types.ts b/src/app/components/ATIAnalytics/types.ts index ac6fd73dbd8..217c5226245 100644 --- a/src/app/components/ATIAnalytics/types.ts +++ b/src/app/components/ATIAnalytics/types.ts @@ -174,6 +174,13 @@ export interface ItemTracker { text?: string; position?: number; duration?: number; + // these fields support portrait video skip-rate analysis in reverb/piano + totalDuration?: number; + completionRate?: number; + skipRate?: number; + navigationMethod?: string; + sessionExitReason?: string; + versionId?: string; resourceId?: string; label?: string; mediaType?: string; diff --git a/src/app/components/MediaLoader/types.ts b/src/app/components/MediaLoader/types.ts index 7474125e7c6..202dc847a4d 100644 --- a/src/app/components/MediaLoader/types.ts +++ b/src/app/components/MediaLoader/types.ts @@ -16,6 +16,8 @@ export type SMPEvent = { direction?: string; method?: 'swipe' | 'wheel'; ended?: boolean; + currentTime?: number; + duration?: number; }; export type MediaPlayerEvents = @@ -23,7 +25,11 @@ export type MediaPlayerEvents = | 'pluginLoaded' | 'fullscreenExit' | 'statsNavigation' - | 'pause'; + | 'pause' + | 'ended' + | 'playing' + | 'timeupdate' + | 'significanttimeupdate'; export type EventMapping = Partial< Record void> diff --git a/src/app/components/PortraitVideoModal/index.tsx b/src/app/components/PortraitVideoModal/index.tsx index 750395d8a6b..b5d8f59751e 100644 --- a/src/app/components/PortraitVideoModal/index.tsx +++ b/src/app/components/PortraitVideoModal/index.tsx @@ -1,7 +1,8 @@ import { Global } from '@emotion/react'; -import { use, useEffect, useRef } from 'react'; +import { use, useCallback, useEffect, useRef } from 'react'; import moment from 'moment-timezone'; import MediaLoader from '#app/components/MediaLoader'; +import { GROUP_3_MIN_WIDTH_BP } from '#app/components/ThemeProvider/mediaQueries'; import { Player, Playlist, @@ -18,6 +19,30 @@ import styles from './index.styles'; import VisuallyHiddenText from '../VisuallyHiddenText'; import { DownArrowIcon, UpArrowIcon } from '../icons'; +// disabled so skip-rate tracking runs on both mobile and desktop +const MOBILE_ONLY_SKIP_RATE_TRACKING = false; +const MOBILE_BREAKPOINT_QUERY = `(max-width: ${GROUP_3_MIN_WIDTH_BP}rem)`; +// this name lets reverb and piano group all skip-rate events together +const SKIP_RATE_COMPONENT_NAME = 'portrait-video-skip-rate'; +const SKIP_RATE_EVENT_GROUPING_NAME = 'portrait-video-skip-rate'; + +// this labels why the current video session ended +type SessionTrackingExitReason = + | 'navigation' + | 'autoplay-end' + | 'playlist-sync' + | 'close-button' + | 'backdrop' + | 'escape' + | 'fullscreen-exit'; + +type ActiveVideoSession = { + index: number; + hasStartedPlaying: boolean; + maxPlayedPositionMs: number; + totalDurationMs: number; +}; + type ModalTrackingParameters = { eventTrackingData: EventTrackingData; selectedVideo: PortraitClipMediaBlock; @@ -53,6 +78,77 @@ const getEventTrackingData = ({ }; }; +// this gates skip-rate tracking so we can ship mobile first and expand later +const shouldTrackSkipRate = () => { + if (!MOBILE_ONLY_SKIP_RATE_TRACKING) { + return true; + } + + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches + ); +}; + +const isValidVideoIndex = ( + videoIndex: number, + blocks: PortraitClipMediaBlock[], +) => videoIndex >= 0 && videoIndex < blocks.length; + +// this uses the metadata duration as a fallback until smp gives us a live duration +const getTotalDurationMs = ({ + videoIndex, + blocks, +}: { + videoIndex: number; + blocks: PortraitClipMediaBlock[]; +}) => { + if (!isValidVideoIndex(videoIndex, blocks)) { + return 0; + } + + const totalDurationMs = moment + .duration(blocks[videoIndex]?.model?.video?.version?.duration) + .asMilliseconds(); + + return Number.isFinite(totalDurationMs) && totalDurationMs > 0 + ? totalDurationMs + : 0; +}; + +// smp docs say timeupdate values are in seconds so we normalise to milliseconds here +const getMillisecondsFromSmpTime = (value?: number) => { + if (!Number.isFinite(value) || value == null || value < 0) { + return null; + } + + return value * 1000; +}; + +// this converts watched time into completion and skip fractions in the 0..1 range +const calculateSkipRateMetrics = ({ + watchedDurationMs, + totalDurationMs, +}: { + watchedDurationMs: number; + totalDurationMs: number; +}) => { + const boundedWatchedDurationMs = Math.min( + Math.max(watchedDurationMs, 0), + totalDurationMs, + ); + const completionRate = Number( + (boundedWatchedDurationMs / totalDurationMs).toFixed(4), + ); + + return { + watchedDurationMs: boundedWatchedDurationMs, + completionRate, + skipRate: Number((1 - completionRate).toFixed(4)), + }; +}; + const getPlayerInstance = () => window?.embeddedMedia?.api?.players()?.bbcMediaPlayer0; @@ -143,10 +239,13 @@ export const statsNavigationCallback = async ( const currentIndex = getCurrentIndex({ e, blocks }); const newIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; + const selectedVideo = blocks?.[newIndex]; + + if (!selectedVideo) return; const newEventTrackingData = getEventTrackingData({ eventTrackingData, - selectedVideo: blocks?.[newIndex], + selectedVideo, selectedVideoIndex: newIndex, }); @@ -155,23 +254,25 @@ export const statsNavigationCallback = async ( }; export const playbackEndedCallback = async ( - e: SMPEvent, + _e: SMPEvent, blocks: PortraitClipMediaBlock[], eventTrackingData: EventTrackingData, swipeTracker: ReturnType, ) => { const player = getPlayerInstance(); - const { ended } = e; - const { autoplay } = player.settings(); + const autoplay = Boolean(player?.settings?.().autoplay); - if (ended && autoplay) { + if (player && autoplay) { const currentIndex = getCurrentIndex({ blocks, player }); const newIndex = currentIndex + 1; + const selectedVideo = blocks?.[newIndex]; + + if (!selectedVideo) return; const newEventTrackingData = getEventTrackingData({ eventTrackingData, - selectedVideo: blocks?.[newIndex], + selectedVideo, selectedVideoIndex: newIndex, }); @@ -184,12 +285,6 @@ const pluginLoadedCallback = () => { player.dispatchEvent('fullScreenPlugin.launchFullscreen'); }; -const handlePrevNextVideo = (direction: 'previous' | 'next') => { - const player = getPlayerInstance(); - - player?.[direction]?.(); -}; - export interface PortraitVideoModalProps { blocks: PortraitClipMediaBlock[]; onClose: () => void; @@ -229,20 +324,336 @@ const PortraitVideoModal = ({ selectedVideoIndex, }), ); + // this sends the custom skip-rate event while reusing existing view tracking transport + const skipRateTracker = useSwipeTracker({ + ...eventTrackingData, + componentName: SKIP_RATE_COMPONENT_NAME, + eventGroupingName: SKIP_RATE_EVENT_GROUPING_NAME, + alwaysInView: true, + }); const closeButtonRef = useRef(null); const endOfContentButtonRef = useRef(null); + // this stores playback state for the currently active video in the modal + const activeVideoSessionRef = useRef(null); + // this avoids duplicate close tracking when multiple close paths fire + const modalHasClosedRef = useRef(false); + // this remembers the last transition reason until the next playlist is loaded + const pendingSessionExitReasonRef = useRef( + null, + ); + // this remembers how the user asked to move to the next video + const pendingNavigationMethodRef = useRef(undefined); + + // clears any pending transition hints once they have been used + const clearPendingVideoTransitionTracking = useCallback(() => { + pendingSessionExitReasonRef.current = null; + pendingNavigationMethodRef.current = undefined; + }, []); + + // this resets tracking for whichever video is active now + const startVideoSession = useCallback( + (videoIndex: number) => { + if (!shouldTrackSkipRate()) return; + + if (!isValidVideoIndex(videoIndex, blocks)) { + activeVideoSessionRef.current = null; + clearPendingVideoTransitionTracking(); + return; + } + + activeVideoSessionRef.current = { + index: videoIndex, + hasStartedPlaying: false, + maxPlayedPositionMs: 0, + totalDurationMs: getTotalDurationMs({ videoIndex, blocks }), + }; + + clearPendingVideoTransitionTracking(); + }, + [blocks, clearPendingVideoTransitionTracking], + ); + + // smp docs say timeupdate gives us currentTime and duration + // we keep the highest playhead reached so we can measure actual playback instead of modal dwell time + const updateActiveVideoPlaybackPosition = useCallback( + (e?: SMPEvent) => { + const activeVideoSession = activeVideoSessionRef.current; + + if ( + !activeVideoSession || + !isValidVideoIndex(activeVideoSession.index, blocks) + ) { + return; + } + + const currentPlayedPositionMs = getMillisecondsFromSmpTime( + e?.currentTime, + ); + const currentTotalDurationMs = getMillisecondsFromSmpTime(e?.duration); + + if (currentTotalDurationMs != null && currentTotalDurationMs > 0) { + activeVideoSession.totalDurationMs = currentTotalDurationMs; + } + + if (currentPlayedPositionMs != null) { + activeVideoSession.maxPlayedPositionMs = Math.max( + activeVideoSession.maxPlayedPositionMs, + currentPlayedPositionMs, + ); + + if (currentPlayedPositionMs > 0) { + activeVideoSession.hasStartedPlaying = true; + } + } + }, + [blocks], + ); + + // this marks the session as real playback once smp says the video is playing + const handlePlaying = useCallback( + (e: SMPEvent) => { + updateActiveVideoPlaybackPosition(e); + + if (activeVideoSessionRef.current) { + activeVideoSessionRef.current.hasStartedPlaying = true; + } + }, + [updateActiveVideoPlaybackPosition], + ); + + // this keeps the session playhead in sync with the player while the video is running + const handlePlaybackProgress = useCallback( + (e: SMPEvent) => { + updateActiveVideoPlaybackPosition(e); + }, + [updateActiveVideoPlaybackPosition], + ); + + // this emits one skip-rate event for the active video session + // if playback never actually started we skip the event so failed loads do not look like real skips + const trackSkipRateForActiveVideo = useCallback( + async ({ + sessionExitReason, + navigationMethod, + }: { + sessionExitReason: SessionTrackingExitReason; + navigationMethod?: string; + }) => { + if (!shouldTrackSkipRate()) return; + + const activeVideoSession = activeVideoSessionRef.current; + + if ( + !activeVideoSession || + !isValidVideoIndex(activeVideoSession.index, blocks) + ) { + return; + } + + const activeVideo = blocks[activeVideoSession.index]; + const totalDurationMs = + activeVideoSession.totalDurationMs || + getTotalDurationMs({ + videoIndex: activeVideoSession.index, + blocks, + }); + + if (!Number.isFinite(totalDurationMs) || totalDurationMs <= 0) return; + + if ( + !activeVideoSession.hasStartedPlaying && + activeVideoSession.maxPlayedPositionMs <= 0 + ) { + return; + } + + const { watchedDurationMs, completionRate, skipRate } = + calculateSkipRateMetrics({ + watchedDurationMs: activeVideoSession.maxPlayedPositionMs, + totalDurationMs, + }); + + await skipRateTracker({ + ...eventTrackingData, + componentName: SKIP_RATE_COMPONENT_NAME, + eventGroupingName: SKIP_RATE_EVENT_GROUPING_NAME, + alwaysInView: true, + groupTracker: { + ...eventTrackingData.groupTracker, + type: 'portrait-video-modal', + }, + itemTracker: { + type: 'portrait-video', + text: activeVideo?.model?.video?.title, + mediaType: 'video', + position: activeVideoSession.index + 1, + duration: watchedDurationMs, + totalDuration: totalDurationMs, + completionRate, + skipRate, + navigationMethod, + sessionExitReason, + versionId: activeVideo?.model?.video?.version?.id, + resourceId: activeVideo?.model?.video?.id, + }, + }); + }, + [blocks, eventTrackingData, skipRateTracker], + ); + + // tracks the current session and clears it straight away so stale smp events cannot spill into the next video + const trackAndClearActiveVideoSession = useCallback( + ({ + sessionExitReason, + navigationMethod, + }: { + sessionExitReason: SessionTrackingExitReason; + navigationMethod?: string; + }) => { + const trackingPromise = trackSkipRateForActiveVideo({ + sessionExitReason, + navigationMethod, + }).catch(() => undefined); + + activeVideoSessionRef.current = null; + clearPendingVideoTransitionTracking(); + + return trackingPromise; + }, + [clearPendingVideoTransitionTracking, trackSkipRateForActiveVideo], + ); + + // centralises close tracking so every close path behaves the same way + const handleModalClose = useCallback( + (sessionExitReason: SessionTrackingExitReason) => { + if (!modalHasClosedRef.current) { + modalHasClosedRef.current = true; + trackAndClearActiveVideoSession({ sessionExitReason }); + } + + onClose(); + }, + [onClose, trackAndClearActiveVideoSession], + ); + + // keeps session tracking in sync with whichever playlist smp loads next + const handlePlaylistLoaded = useCallback( + (e: SMPEvent) => { + const currentIndex = getCurrentIndex({ e, blocks }); + const activeVideoIndex = activeVideoSessionRef.current?.index; + + if (isValidVideoIndex(currentIndex, blocks)) { + if (activeVideoIndex == null) { + startVideoSession(currentIndex); + } else if (activeVideoIndex !== currentIndex) { + trackAndClearActiveVideoSession({ + sessionExitReason: + pendingSessionExitReasonRef.current ?? 'playlist-sync', + navigationMethod: + pendingNavigationMethodRef.current ?? 'playlistLoaded', + }); + startVideoSession(currentIndex); + } else if (pendingSessionExitReasonRef.current) { + clearPendingVideoTransitionTracking(); + } + } + + playlistLoadedCallback(e, blocks); + }, + [ + blocks, + clearPendingVideoTransitionTracking, + startVideoSession, + trackAndClearActiveVideoSession, + ], + ); + + // records swipe and wheel intent and then waits for the next playlist load before ending the current session + const handleStatsNavigation = useCallback( + async (e: SMPEvent) => { + pendingSessionExitReasonRef.current = 'navigation'; + pendingNavigationMethodRef.current = e?.method ?? 'unknown'; + + await statsNavigationCallback(e, blocks, eventTrackingData, swipeTracker); + }, + [blocks, eventTrackingData, swipeTracker], + ); + + // marks the session as completed when smp says the current item has ended + // autoplay is handled here but the next session waits for the next playlist to actually load + const handlePlaybackEnded = useCallback( + async (e: SMPEvent) => { + updateActiveVideoPlaybackPosition(e); + + const player = getPlayerInstance(); + const autoplayEnabled = Boolean(player?.settings?.().autoplay); + const activeVideoSession = activeVideoSessionRef.current; + + if (activeVideoSession) { + const completedDurationMs = + activeVideoSession.totalDurationMs || + getTotalDurationMs({ + videoIndex: activeVideoSession.index, + blocks, + }); + + if (completedDurationMs > 0) { + activeVideoSession.totalDurationMs = completedDurationMs; + activeVideoSession.maxPlayedPositionMs = Math.max( + activeVideoSession.maxPlayedPositionMs, + completedDurationMs, + ); + activeVideoSession.hasStartedPlaying = true; + } + } + + if (autoplayEnabled) { + await trackAndClearActiveVideoSession({ + sessionExitReason: 'autoplay-end', + navigationMethod: 'autoplay', + }); + } + + await playbackEndedCallback(e, blocks, eventTrackingData, swipeTracker); + }, + [ + blocks, + eventTrackingData, + swipeTracker, + trackAndClearActiveVideoSession, + updateActiveVideoPlaybackPosition, + ], + ); + + // this starts the initial session when the modal opens + useEffect(() => { + startVideoSession(selectedVideoIndex); + }, [selectedVideoIndex, startVideoSession]); + + // sets the expected reason before we ask smp to move with the desktop buttons + const handlePrevNextVideo = useCallback((direction: 'previous' | 'next') => { + const player = getPlayerInstance(); + + if (!player) { + return; + } + + pendingSessionExitReasonRef.current = 'navigation'; + pendingNavigationMethodRef.current = 'button'; + player[direction](); + }, []); useEffect(() => { const handleBackdropClick = (event: MouseEvent | TouchEvent) => { if (event.target === event.currentTarget) { - onClose(); + handleModalClose('backdrop'); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + handleModalClose('escape'); } // - Tab/Shift+Tab loops focus between the close button and the end-of-content button if (event.key === 'Tab') { @@ -267,7 +678,7 @@ const PortraitVideoModal = ({ if (modal) { closeButtonRef.current?.focus(); - // Prevent tabbing to elements outside the modal + // prevent tabbing to elements outside the modal reactRootElement?.setAttribute('inert', 'true'); modal.addEventListener('mousedown', handleBackdropClick); modal.addEventListener('touchstart', handleBackdropClick); @@ -280,11 +691,15 @@ const PortraitVideoModal = ({ modal?.removeEventListener('touchstart', handleBackdropClick); modal?.removeEventListener('keydown', handleKeyDown); + // clear local tracking on teardown without sending analytics + clearPendingVideoTransitionTracking(); + activeVideoSessionRef.current = null; + const player = getPlayerInstance(); - // Pause any player if the modal is closed instantly + // pause any player if the modal is closed instantly if (player) player.pause(); }; - }, [onClose]); + }, [clearPendingVideoTransitionTracking, handleModalClose]); return ( <> @@ -303,7 +718,7 @@ const PortraitVideoModal = ({ data-testid="close-modal-button" css={styles.closeButton} className="focusIndicatorInvert" - onClick={onClose} + onClick={() => handleModalClose('close-button')} > {navigationIcons.cross} {closeVideo} @@ -337,18 +752,14 @@ const PortraitVideoModal = ({ css={styles.mediaWrapper} blocks={[blocks?.[selectedVideoIndex]]} eventMapping={{ - playlistLoaded: e => playlistLoadedCallback(e, blocks), + playlistLoaded: handlePlaylistLoaded, pluginLoaded: pluginLoadedCallback, - fullscreenExit: onClose, - statsNavigation: e => - statsNavigationCallback( - e, - blocks, - eventTrackingData, - swipeTracker, - ), - pause: e => - playbackEndedCallback(e, blocks, eventTrackingData, swipeTracker), + fullscreenExit: () => handleModalClose('fullscreen-exit'), + statsNavigation: handleStatsNavigation, + ended: handlePlaybackEnded, + playing: handlePlaying, + timeupdate: handlePlaybackProgress, + significanttimeupdate: handlePlaybackProgress, }} />