Add video playback duration analytics
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user