diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 42e9ec2124..8e48bfb077 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1425,6 +1425,28 @@ export type Events = { presentation: 'video' | 'gif' autoplay: boolean } + /** + * Incremental, non-overlapping wall-clock watch time. Segments are uniquely + * identified by playbackSessionId + segmentIndex for downstream deduping. + */ + 'video:playback:duration': { + playbackSessionId: string + segmentIndex: number + durationMs: number + postUri?: string + postAuthorDid?: string + context: 'embed' | 'immersiveFeed' + presentation: 'video' | 'gif' + endReason: + | 'paused' + | 'deactivated' + | 'backgrounded' + | 'buffering' + | 'checkpoint' + | 'ended' + | 'error' + | 'unmounted' + } /** * The user activated a third-party media player. Cross-origin players do * not expose confirmed playback consistently, so this must not be treated diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index c85409c3ec..2e2efb2cc1 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -28,6 +28,7 @@ export function VideoEmbedInnerNative({ setIsLoading, setIsActive, onPlaybackStart, + onPlaybackProgress, onError, }: { ref: React.Ref<{togglePlayback: () => void}> @@ -36,6 +37,7 @@ export function VideoEmbedInnerNative({ setIsLoading: (isLoading: boolean) => void setIsActive: (isActive: boolean) => void onPlaybackStart: (autoplay: boolean) => void + onPlaybackProgress: (progressSeconds: number) => void /** * Called with the native error message before the component throws to the * surrounding error boundary. @@ -107,6 +109,9 @@ export function VideoEmbedInnerNative({ playbackStartTrackedRef.current = true onPlaybackStart(autoplay) } + onPlaybackProgress( + Math.max(0, maxTimeRemainingSeconds.current - timeRemaining), + ) if (!isGif && reportDialogMetadata) { reportDialogMetadata.current.videoTimestampSeconds = Math.max( 0, diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index 56fb66f18e..e0e408f9e7 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -7,6 +7,9 @@ export type VideoEmbedInnerWebProps = { onScreen: boolean lastKnownTime: React.RefObject onPlaybackStart: (autoplay: boolean) => void + onPlaybackProgress: (progressSeconds: number) => void + onPlaybackStateChange: (state: 'playing' | 'paused' | 'buffering') => void + onPlaybackEnd: () => void } export class HLSUnsupportedError extends Error { diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 213dbfa911..43b8ff7459 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -31,6 +31,9 @@ export function VideoEmbedInnerWeb({ onScreen, lastKnownTime, onPlaybackStart, + onPlaybackProgress, + onPlaybackStateChange, + onPlaybackEnd, }: VideoEmbedInnerWebProps) { const containerRef = useRef(null) const videoRef = useRef(null) @@ -82,6 +85,7 @@ export function VideoEmbedInnerWeb({ onTimeUpdate={e => { const currentTime = e.currentTarget.currentTime lastKnownTime.current = currentTime + onPlaybackProgress(currentTime) if ( !playbackStartTrackedRef.current && hasPlaybackStarted(currentTime) @@ -98,6 +102,15 @@ export function VideoEmbedInnerWeb({ reportDialogMetadata.current.videoTimestampSeconds = currentTime } }} + onPlaying={() => onPlaybackStateChange('playing')} + onPause={() => onPlaybackStateChange('paused')} + onWaiting={() => onPlaybackStateChange('buffering')} + onCanPlay={() => { + if (!videoRef.current?.paused) { + onPlaybackStateChange('playing') + } + }} + onEnded={onPlaybackEnd} loop={loop} /> {embed.alt && ( diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index 66ec01da9f..26231ed282 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -5,6 +5,8 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {getCurrentState, useOnAppStateChange} from '#/lib/appState' +import {createPlaybackDurationTracker} from '#/lib/media/video/analytics' import { createPlaybackTelemetry, type PlaybackTelemetry, @@ -89,11 +91,30 @@ function InnerWrapper({embed, post}: Props) { const telemetryRef = useRef(null) const impressionTrackedRef = useRef(false) const playbackStartTrackedRef = useRef(false) + const [durationTracker] = useState(() => { + const tracker = createPlaybackDurationTracker({ + onSegment: segment => { + ax.metric('video:playback:duration', { + ...segment, + postUri: post?.uri, + postAuthorDid: post?.author.did, + context: 'embed', + presentation: embed.presentation === 'gif' ? 'gif' : 'video', + }) + }, + }) + tracker.setForeground(getCurrentState() === 'active') + return tracker + }) + useOnAppStateChange(state => { + durationTracker.setForeground(state === 'active') + }) useEffect(() => { return () => { telemetryRef.current?.deactivated() + durationTracker.flush('unmounted') } - }, []) + }, [durationTracker]) const showOverlay = !isActive || @@ -111,18 +132,21 @@ function InnerWrapper({embed, post}: Props) { embed={embed} setStatus={s => { setStatus(s) + durationTracker.setPlaying(s === 'playing') if (s === 'playing') { telemetryRef.current?.playing() } }} setIsLoading={loading => { setIsLoading(loading) + durationTracker.setBuffering(loading) if (!loading) { telemetryRef.current?.ready() } }} setIsActive={active => { setIsActive(active) + durationTracker.setActive(active) if (active) { if (!impressionTrackedRef.current) { impressionTrackedRef.current = true @@ -155,7 +179,11 @@ function InnerWrapper({embed, post}: Props) { autoplay, }) }} + onPlaybackProgress={progressSeconds => { + durationTracker.observeProgress(progressSeconds) + }} onError={error => { + durationTracker.flush('error') telemetryRef.current?.error(error) ax.metric('video:playback:failed', { surface: 'feed', diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 0c3f217791..6b20916b96 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -10,6 +10,8 @@ import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {getCurrentState, useOnAppStateChange} from '#/lib/appState' +import {createPlaybackDurationTracker} from '#/lib/media/video/analytics' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, useTheme} from '#/alf' import {useIsWithinMessage} from '#/components/dms/MessageContext' @@ -63,6 +65,33 @@ export function VideoEmbed({ const isGif = embed.presentation === 'gif' // GIFs don't participate in the "one video at a time" system const active = isGif || activeFromContext + const [durationTracker] = useState(() => { + const tracker = createPlaybackDurationTracker({ + onSegment: segment => { + ax.metric('video:playback:duration', { + ...segment, + postUri: post?.uri, + postAuthorDid: post?.author.did, + context: 'embed', + presentation: isGif ? 'gif' : 'video', + }) + }, + }) + tracker.setForeground(getCurrentState() === 'active') + return tracker + }) + + useOnAppStateChange(state => { + durationTracker.setForeground(state === 'active') + }) + + useEffect(() => { + durationTracker.setActive(active && onScreen) + }, [active, durationTracker, onScreen]) + + useEffect(() => { + return () => durationTracker.flush('unmounted') + }, [durationTracker]) useEffect(() => { if (!meaningfullyVisible || impressionTrackedRef.current) return @@ -214,6 +243,14 @@ export function VideoEmbed({ autoplay, }) }} + onPlaybackProgress={progressSeconds => { + durationTracker.observeProgress(progressSeconds) + }} + onPlaybackStateChange={state => { + durationTracker.setBuffering(state === 'buffering') + durationTracker.setPlaying(state === 'playing') + }} + onPlaybackEnd={() => durationTracker.flush('ended')} /> diff --git a/src/lib/media/video/__tests__/analytics.test.ts b/src/lib/media/video/__tests__/analytics.test.ts index 846c8c245b..1a207eaa59 100644 --- a/src/lib/media/video/__tests__/analytics.test.ts +++ b/src/lib/media/video/__tests__/analytics.test.ts @@ -1,4 +1,8 @@ -import {hasPlaybackStarted} from '../analytics' +import { + createPlaybackDurationTracker, + hasPlaybackStarted, + type PlaybackDurationSegment, +} from '../analytics' describe('hasPlaybackStarted', () => { it.each([ @@ -12,3 +16,122 @@ describe('hasPlaybackStarted', () => { expect(hasPlaybackStarted(seconds)).toBe(expected) }) }) + +describe('createPlaybackDurationTracker', () => { + let time = 0 + let segments: PlaybackDurationSegment[] + + const createTracker = () => + createPlaybackDurationTracker({ + now: () => time, + playbackSessionId: 'session', + onSegment: segment => segments.push(segment), + }) + + beforeEach(() => { + time = 0 + segments = [] + }) + + it('emits non-overlapping segments across pause and resume', () => { + const tracker = createTracker() + tracker.setActive(true) + tracker.setPlaying(true) + tracker.observeProgress(0) + time = 1_000 + tracker.observeProgress(1) + tracker.setPlaying(false) + tracker.setPlaying(true) + time = 1_500 + tracker.observeProgress(1) + time = 2_000 + tracker.observeProgress(1.5) + tracker.setActive(false) + + expect(segments).toEqual([ + { + playbackSessionId: 'session', + segmentIndex: 0, + durationMs: 1_000, + endReason: 'paused', + }, + { + playbackSessionId: 'session', + segmentIndex: 1, + durationMs: 500, + endReason: 'deactivated', + }, + ]) + }) + + it('counts wall time rather than a seek or loop position delta', () => { + const tracker = createTracker() + tracker.setActive(true) + tracker.setPlaying(true) + tracker.observeProgress(1) + time = 500 + tracker.observeProgress(50) + time = 1_000 + tracker.observeProgress(0) + tracker.setPlaying(false) + + expect(segments[0]?.durationMs).toBe(1_000) + }) + + it('does not count stalled playback or a suspended timer gap', () => { + const tracker = createTracker() + tracker.setActive(true) + tracker.setPlaying(true) + tracker.observeProgress(0) + time = 1_000 + tracker.observeProgress(0) + time = 10_000 + tracker.observeProgress(1) + tracker.setPlaying(false) + + expect(segments).toEqual([]) + }) + + it('flushes only once for repeated lifecycle callbacks', () => { + const tracker = createTracker() + tracker.setActive(true) + tracker.setPlaying(true) + tracker.observeProgress(0) + time = 500 + tracker.observeProgress(0.5) + tracker.setActive(false) + tracker.setActive(false) + tracker.setPlaying(false) + + expect(segments).toHaveLength(1) + }) + + it('checkpoints long playback without overlapping the final segment', () => { + const tracker = createTracker() + tracker.setActive(true) + tracker.setPlaying(true) + tracker.observeProgress(0) + for (let second = 1; second <= 31; second++) { + time = second * 1_000 + tracker.observeProgress(second) + } + time = 32_000 + tracker.observeProgress(32) + tracker.setPlaying(false) + + expect(segments).toEqual([ + { + playbackSessionId: 'session', + segmentIndex: 0, + durationMs: 30_000, + endReason: 'checkpoint', + }, + { + playbackSessionId: 'session', + segmentIndex: 1, + durationMs: 2_000, + endReason: 'paused', + }, + ]) + }) +}) diff --git a/src/lib/media/video/analytics.ts b/src/lib/media/video/analytics.ts index 2bae36f4f8..47cb46c683 100644 --- a/src/lib/media/video/analytics.ts +++ b/src/lib/media/video/analytics.ts @@ -1,4 +1,9 @@ +import {nanoid} from 'nanoid/non-secure' + export const PLAYBACK_START_THRESHOLD_SECONDS = 0.05 +export const MIN_PLAYBACK_DURATION_SEGMENT_MS = 250 +export const MAX_PLAYBACK_PROGRESS_GAP_MS = 2_500 +export const PLAYBACK_DURATION_CHECKPOINT_MS = 30_000 /** * A small positive threshold distinguishes rendered playback from metadata @@ -11,3 +16,116 @@ export function hasPlaybackStarted(progressSeconds: number): boolean { progressSeconds >= PLAYBACK_START_THRESHOLD_SECONDS ) } + +export type PlaybackDurationEndReason = + | 'paused' + | 'deactivated' + | 'backgrounded' + | 'buffering' + | 'checkpoint' + | 'ended' + | 'error' + | 'unmounted' + +export type PlaybackDurationSegment = { + playbackSessionId: string + segmentIndex: number + durationMs: number + endReason: PlaybackDurationEndReason +} + +export type PlaybackDurationTracker = ReturnType< + typeof createPlaybackDurationTracker +> + +/** + * Counts wall-clock time between advancing playback callbacks. Using wall time + * means seeking cannot inflate the result; requiring progress callbacks means + * buffering and suspended JS cannot create watch time either. + */ +export function createPlaybackDurationTracker({ + onSegment, + now = () => performance.now(), + playbackSessionId = nanoid(), +}: { + onSegment: (segment: PlaybackDurationSegment) => void + now?: () => number + playbackSessionId?: string +}) { + let active = false + let playing = false + let foreground = true + let buffering = false + let lastPosition: number | undefined + let lastObservedAt: number | undefined + let accumulatedMs = 0 + let segmentIndex = 0 + + const eligible = () => active && playing && foreground && !buffering + + const resetObservation = () => { + lastPosition = undefined + lastObservedAt = undefined + } + + const flush = (endReason: PlaybackDurationEndReason) => { + resetObservation() + const durationMs = Math.round(accumulatedMs) + accumulatedMs = 0 + if (durationMs < MIN_PLAYBACK_DURATION_SEGMENT_MS) return + onSegment({ + playbackSessionId, + segmentIndex: segmentIndex++, + durationMs, + endReason, + }) + } + + const transition = ( + update: () => void, + endReason: PlaybackDurationEndReason, + ) => { + const wasEligible = eligible() + update() + if (wasEligible && !eligible()) flush(endReason) + if (!wasEligible && eligible()) resetObservation() + } + + return { + observeProgress(positionSeconds: number) { + if (!eligible() || !Number.isFinite(positionSeconds)) { + resetObservation() + return + } + const observedAt = now() + if ( + lastObservedAt !== undefined && + lastPosition !== undefined && + positionSeconds !== lastPosition + ) { + const elapsed = observedAt - lastObservedAt + if (elapsed > 0 && elapsed <= MAX_PLAYBACK_PROGRESS_GAP_MS) { + accumulatedMs += elapsed + if (accumulatedMs >= PLAYBACK_DURATION_CHECKPOINT_MS) { + flush('checkpoint') + } + } + } + lastPosition = positionSeconds + lastObservedAt = observedAt + }, + setActive(value: boolean) { + transition(() => (active = value), 'deactivated') + }, + setPlaying(value: boolean) { + transition(() => (playing = value), 'paused') + }, + setForeground(value: boolean) { + transition(() => (foreground = value), 'backgrounded') + }, + setBuffering(value: boolean) { + transition(() => (buffering = value), 'buffering') + }, + flush, + } +} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 9c662fdf13..7c0d5c46ba 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -40,11 +40,15 @@ import { } from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' +import {getCurrentState, useOnAppStateChange} from '#/lib/appState' import {HITSLOP_20} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {hasPlaybackStarted} from '#/lib/media/video/analytics' +import { + createPlaybackDurationTracker, + hasPlaybackStarted, +} from '#/lib/media/video/analytics' import { createPlaybackTelemetry, type PlaybackTelemetry, @@ -618,10 +622,38 @@ function VideoItemInner({ ReportDialogMetadataContext.useReportDialogMetadataContext() const ax = useAnalytics() const playbackStartTrackedRef = useRef(false) + const [durationTracker] = useState(() => { + const tracker = createPlaybackDurationTracker({ + onSegment: segment => { + ax.metric('video:playback:duration', { + ...segment, + postUri: post.uri, + postAuthorDid: post.author.did, + context: 'immersiveFeed', + presentation: embed.presentation === 'gif' ? 'gif' : 'video', + }) + }, + }) + tracker.setForeground(getCurrentState() === 'active') + return tracker + }) + + useOnAppStateChange(state => { + durationTracker.setForeground(state === 'active') + }) + + useEffect(() => { + durationTracker.setActive(active) + }, [active, durationTracker]) + + useEffect(() => { + return () => durationTracker.flush('unmounted') + }, [durationTracker]) usePlaybackTelemetry({player, active, playlist: embed.playlist}) useEventListener(player, 'timeUpdate', evt => { + durationTracker.observeProgress(evt.currentTime) if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) { setIsReady(true) } @@ -654,6 +686,14 @@ function VideoItemInner({ } }) + useEventListener(player, 'playingChange', evt => { + durationTracker.setPlaying(evt.isPlaying) + }) + + useEventListener(player, 'statusChange', evt => { + if (evt.status === 'error') durationTracker.flush('error') + }) + return (