Add video playback analytics events

This commit is contained in:
vineyardbovines
2026-09-01 09:00:06 -04:00
committed by Samuel Newman
parent 7d39aa3422
commit 41c31abfd4
14 changed files with 269 additions and 21 deletions
+35
View File
@@ -1397,6 +1397,41 @@ export type Events = {
playlist: string
}
/**
* The playable video was meaningfully visible. This is an exposure event,
* not proof that playback started. Fires once per mounted video item.
*/
'video:impression': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
}
/**
* Playback advanced far enough to render the first frame. Preloading and
* merely becoming active do not count. Fires once per mounted video item;
* automatic loops do not produce another event.
*/
'video:playback:start': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
autoplay: boolean
}
/**
* The user activated a third-party media player. Cross-origin players do
* not expose confirmed playback consistently, so this must not be treated
* as equivalent to video:playback:start without an explicit methodology.
*/
'externalEmbed:playerActivated': {
postUri?: string
postAuthorDid?: string
source: string
playerType: string
mediaType: 'video' | 'audio' | 'gif' | 'other'
}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
@@ -22,6 +22,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
type EmbedPlayerParams,
getEmbedPlayerMediaType,
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences'
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {KeepAwake} from '#/components/KeepAwake'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons'
@@ -121,9 +123,11 @@ function Player({
export function ExternalPlayer({
link,
params,
post,
}: {
link: app.bsky.embed.external.ViewExternal
params: EmbedPlayerParams
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
@@ -131,10 +135,31 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const ax = useAnalytics()
const [isPlayerActive, setIsPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const activatePlayer = useCallback(() => {
if (!isPlayerActive) {
ax.metric('externalEmbed:playerActivated', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
source: params.source,
playerType: params.type,
mediaType: getEmbedPlayerMediaType(params.type),
})
}
setIsPlayerActive(true)
}, [
ax,
isPlayerActive,
params.source,
params.type,
post?.author.did,
post?.uri,
])
const aspect = useMemo(() => {
return getPlayerAspect({
type: params.type,
@@ -202,14 +227,14 @@ export function ExternalPlayer({
return
}
setIsPlayerActive(true)
activatePlayer()
},
[externalEmbedsPrefs, consentDialogControl, params.source],
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
)
const onAcceptConsent = useCallback(() => {
setIsPlayerActive(true)
}, [])
activatePlayer()
}, [activatePlayer])
return (
<>
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
export const ExternalEmbed = ({
link,
onOpen,
post,
style,
hideAlt,
}: {
link: app.bsky.embed.external.ViewExternal
onOpen?: () => void
post?: app.bsky.feed.defs.PostView
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
{embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
<ExternalPlayer
link={link}
params={embedPlayerParams}
post={post}
/>
) : undefined}
<View
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
setStatus,
setIsLoading,
setIsActive,
onPlaybackStart,
onError,
}: {
ref: React.Ref<{togglePlayback: () => void}>
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void
onPlaybackStart: (autoplay: boolean) => void
/**
* Called with the native error message before the component throws to the
* surrounding error boundary.
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0)
const playbackStartTrackedRef = useRef(false)
const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0)
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
}
const isGif = embed.presentation === 'gif'
const autoplay = !autoplayDisabled && !isWithinMessage
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
autoplay={autoplay}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
onTimeRemainingChange={e => {
const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining)
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current,
timeRemaining,
)
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(
maxTimeRemainingSeconds.current - timeRemaining,
)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(autoplay)
}
if (!isGif && reportDialogMetadata) {
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
}
}}
onError={e => {
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
}
export class HLSUnsupportedError extends Error {
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
setActive,
onScreen,
lastKnownTime,
onPlaybackStart,
}: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext()
const playbackStartTrackedRef = useRef(false)
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
onTimeUpdate={e => {
const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(currentTime)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(!focused)
}
if (
!isGif &&
reportDialogMetadata &&
+24 -1
View File
@@ -23,6 +23,7 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}
export function VideoEmbed({embed}: Props) {
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
)
}
function InnerWrapper({embed}: Props) {
function InnerWrapper({embed, post}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
* the active position cost nothing.
*/
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
useEffect(() => {
return () => {
telemetryRef.current?.deactivated()
@@ -121,6 +124,15 @@ function InnerWrapper({embed}: Props) {
setIsActive={active => {
setIsActive(active)
if (active) {
if (!impressionTrackedRef.current) {
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
if (telemetryRef.current == null) {
telemetryRef.current = createPlaybackTelemetry({
surface: 'feed',
@@ -132,6 +144,17 @@ function InnerWrapper({embed}: Props) {
telemetryRef.current?.deactivated()
}
}}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay,
})
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
@@ -37,7 +37,13 @@ const noop = () => {}
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
export function VideoEmbed({
embed,
post,
}: {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {
@@ -49,11 +55,25 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
const ax = useAnalytics()
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => {
if (!onScreen || impressionTrackedRef.current) return
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
})
}, [ax, isGif, onScreen, post?.author.did, post?.uri])
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -61,7 +81,7 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
entries => {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
setOnScreen(entry.isIntersecting && entry.intersectionRatio >= 0.5)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition(
@@ -179,6 +199,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
autoplay,
})
}}
/>
</OnlyNearScreen>
</ErrorBoundary>
+2 -1
View File
@@ -134,6 +134,7 @@ function MediaEmbed({
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
post={rest.post}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
@@ -144,7 +145,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} />
<VideoEmbed embed={embed.view} post={rest.post} />
</ContentHider>
)
}
@@ -0,0 +1,14 @@
import {hasPlaybackStarted} from '../analytics'
describe('hasPlaybackStarted', () => {
it.each([
[0, false],
[0.049, false],
[0.05, true],
[1, true],
[Number.NaN, false],
[Number.POSITIVE_INFINITY, false],
])('returns %s for %s seconds', (seconds, expected) => {
expect(hasPlaybackStarted(seconds)).toBe(expected)
})
})
+13
View File
@@ -0,0 +1,13 @@
export const PLAYBACK_START_THRESHOLD_SECONDS = 0.05
/**
* A small positive threshold distinguishes rendered playback from metadata
* loading and zero-valued player callbacks while still representing the first
* frame across the frame rates we support.
*/
export function hasPlaybackStarted(progressSeconds: number): boolean {
return (
Number.isFinite(progressSeconds) &&
progressSeconds >= PLAYBACK_START_THRESHOLD_SECONDS
)
}
+20
View File
@@ -0,0 +1,20 @@
import {type EmbedPlayerType, getEmbedPlayerMediaType} from './embed-player'
describe('getEmbedPlayerMediaType', () => {
it.each<
readonly [EmbedPlayerType, ReturnType<typeof getEmbedPlayerMediaType>]
>([
['youtube_video', 'video'],
['youtube_short', 'video'],
['twitch_video', 'video'],
['vimeo_video', 'video'],
['spotify_song', 'audio'],
['soundcloud_set', 'audio'],
['apple_music_album', 'audio'],
['bandcamp_track', 'audio'],
['giphy_gif', 'gif'],
['flickr_album', 'other'],
])('classifies %s as %s', (type, expected) => {
expect(getEmbedPlayerMediaType(type)).toBe(expected)
})
})
+23
View File
@@ -49,6 +49,29 @@ export type EmbedPlayerType =
| 'bandcamp_album'
| 'bandcamp_track'
export function getEmbedPlayerMediaType(
type: EmbedPlayerType,
): 'video' | 'audio' | 'gif' | 'other' {
if (
type === 'youtube_video' ||
type === 'youtube_short' ||
type === 'twitch_video' ||
type === 'vimeo_video'
) {
return 'video'
}
if (type.endsWith('_gif')) return 'gif'
if (
type.startsWith('spotify_') ||
type.startsWith('soundcloud_') ||
type.startsWith('apple_music_') ||
type.startsWith('bandcamp_')
) {
return 'audio'
}
return 'other'
}
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts',
+36 -1
View File
@@ -44,6 +44,7 @@ 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 {
createPlaybackTelemetry,
type PlaybackTelemetry,
@@ -492,9 +493,19 @@ let VideoItem = ({
const {width, height} = useSafeAreaFrame()
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
const hasTrackedView = useRef(false)
const hasTrackedVideoImpression = useRef(false)
useEffect(() => {
if (active) {
if (!hasTrackedVideoImpression.current) {
hasTrackedVideoImpression.current = true
ax.metric('video:impression', {
postUri: post.uri,
postAuthorDid: post.author.did,
context: 'immersiveFeed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionSeen',
@@ -519,6 +530,7 @@ let VideoItem = ({
active,
post.uri,
post.author.did,
embed.presentation,
feedContext,
reqId,
sendInteraction,
@@ -557,7 +569,12 @@ let VideoItem = ({
<>
<VideoItemPlaceholder embed={embed} />
{shouldRenderVideo && player && (
<VideoItemInner player={player} embed={embed} active={active} />
<VideoItemInner
player={player}
embed={embed}
post={post}
active={active}
/>
)}
{moderation && (
<Overlay
@@ -587,16 +604,20 @@ VideoItem = memo(VideoItem)
function VideoItemInner({
player,
embed,
post,
active,
}: {
player: VideoPlayer
embed: app.bsky.embed.video.View
post: app.bsky.feed.defs.PostView
active: boolean
}) {
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
const reportDialogMetadata =
ReportDialogMetadataContext.useReportDialogMetadataContext()
const ax = useAnalytics()
const playbackStartTrackedRef = useRef(false)
usePlaybackTelemetry({player, active, playlist: embed.playlist})
@@ -617,6 +638,20 @@ function VideoItemInner({
) {
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime
}
if (
active &&
!playbackStartTrackedRef.current &&
hasPlaybackStarted(evt.currentTime)
) {
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post.uri,
postAuthorDid: post.author.did,
context: 'immersiveFeed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay: true,
})
}
})
return (