Add video playback duration analytics

This commit is contained in:
vineyardbovines
2026-09-04 10:27:47 -04:00
parent 8af501de62
commit bb0077d925
9 changed files with 392 additions and 3 deletions
+22
View File
@@ -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
@@ -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,
@@ -7,6 +7,9 @@ export type VideoEmbedInnerWebProps = {
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
onPlaybackProgress: (progressSeconds: number) => void
onPlaybackStateChange: (state: 'playing' | 'paused' | 'buffering') => void
onPlaybackEnd: () => void
}
export class HLSUnsupportedError extends Error {
@@ -31,6 +31,9 @@ export function VideoEmbedInnerWeb({
onScreen,
lastKnownTime,
onPlaybackStart,
onPlaybackProgress,
onPlaybackStateChange,
onPlaybackEnd,
}: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(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 && (
+29 -1
View File
@@ -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<PlaybackTelemetry | null>(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',
@@ -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')}
/>
</OnlyNearScreen>
</ErrorBoundary>
+124 -1
View File
@@ -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',
},
])
})
})
+118
View File
@@ -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,
}
}
+41 -1
View File
@@ -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 (
<VideoView
accessible={false}