Add video playback analytics events (#11629)

This commit is contained in:
Spence Pope
2026-09-02 11:59:44 -04:00
committed by GitHub
parent ae971db765
commit 99715b9a67
14 changed files with 274 additions and 22 deletions
+35
View File
@@ -1397,6 +1397,41 @@ export type Events = {
playlist: string 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) === // === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload // Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g. // 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 NavigationProp} from '#/lib/routes/types'
import { import {
type EmbedPlayerParams, type EmbedPlayerParams,
getEmbedPlayerMediaType,
getPlayerAspect, getPlayerAspect,
} from '#/lib/strings/embed-player' } from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences' import {useExternalEmbedsPrefs} from '#/state/preferences'
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill' import {Fill} from '#/components/Fill'
import {KeepAwake} from '#/components/KeepAwake' import {KeepAwake} from '#/components/KeepAwake'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons' import {type app} from '#/lexicons'
@@ -121,9 +123,11 @@ function Player({
export function ExternalPlayer({ export function ExternalPlayer({
link, link,
params, params,
post,
}: { }: {
link: app.bsky.embed.external.ViewExternal link: app.bsky.embed.external.ViewExternal
params: EmbedPlayerParams params: EmbedPlayerParams
post?: app.bsky.feed.defs.PostView
}) { }) {
const t = useTheme() const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -131,10 +135,31 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions() const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs() const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl() const consentDialogControl = useDialogControl()
const ax = useAnalytics()
const [isPlayerActive, setIsPlayerActive] = useState(false) const [isPlayerActive, setIsPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true) 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(() => { const aspect = useMemo(() => {
return getPlayerAspect({ return getPlayerAspect({
type: params.type, type: params.type,
@@ -202,14 +227,14 @@ export function ExternalPlayer({
return return
} }
setIsPlayerActive(true) activatePlayer()
}, },
[externalEmbedsPrefs, consentDialogControl, params.source], [externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
) )
const onAcceptConsent = useCallback(() => { const onAcceptConsent = useCallback(() => {
setIsPlayerActive(true) activatePlayer()
}, []) }, [activatePlayer])
return ( return (
<> <>
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
export const ExternalEmbed = ({ export const ExternalEmbed = ({
link, link,
onOpen, onOpen,
post,
style, style,
hideAlt, hideAlt,
}: { }: {
link: app.bsky.embed.external.ViewExternal link: app.bsky.embed.external.ViewExternal
onOpen?: () => void onOpen?: () => void
post?: app.bsky.feed.defs.PostView
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
hideAlt?: boolean hideAlt?: boolean
}) => { }) => {
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
{embedPlayerParams?.isGif ? ( {embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} /> <ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? ( ) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} /> <ExternalPlayer
link={link}
params={embedPlayerParams}
post={post}
/>
) : undefined} ) : undefined}
<View <View
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {useAutoplayDisabled} from '#/state/preferences' import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
setStatus, setStatus,
setIsLoading, setIsLoading,
setIsActive, setIsActive,
onPlaybackStart,
onError, onError,
}: { }: {
ref: React.Ref<{togglePlayback: () => void}> ref: React.Ref<{togglePlayback: () => void}>
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
setStatus: (status: 'playing' | 'paused') => void setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void setIsActive: (isActive: boolean) => void
onPlaybackStart: (autoplay: boolean) => void
/** /**
* Called with the native error message before the component throws to the * Called with the native error message before the component throws to the
* surrounding error boundary. * surrounding error boundary.
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
const [muted, setMuted] = useVideoMuteState() const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext() const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0) const maxTimeRemainingSeconds = useRef(0)
const playbackStartTrackedRef = useRef(false)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0) const [timeRemaining, setTimeRemaining] = useState(0)
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
} }
const isGif = embed.presentation === 'gif' const isGif = embed.presentation === 'gif'
const autoplay = !autoplayDisabled && !isWithinMessage
return ( return (
<View style={[a.flex_1, a.relative]}> <View style={[a.flex_1, a.relative]}>
<BlueskyVideoView <BlueskyVideoView
url={embed.playlist} url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage} autoplay={autoplay}
beginMuted={isGif || (autoplayDisabled ? false : muted)} beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]} style={[a.rounded_sm]}
onActiveChange={e => { onActiveChange={e => {
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
onTimeRemainingChange={e => { onTimeRemainingChange={e => {
const {timeRemaining} = e.nativeEvent const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining) setTimeRemaining(timeRemaining)
if ( if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
maxTimeRemainingSeconds.current = Math.max( maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current, maxTimeRemainingSeconds.current,
timeRemaining, timeRemaining,
) )
reportDialogMetadata.current.videoTimestampSeconds = Math.max( if (
0, !playbackStartTrackedRef.current &&
maxTimeRemainingSeconds.current - timeRemaining, hasPlaybackStarted(
) maxTimeRemainingSeconds.current - timeRemaining,
)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(autoplay)
}
if (!isGif && reportDialogMetadata) {
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
} }
}} }}
onError={e => { onError={e => {
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
setActive: () => void setActive: () => void
onScreen: boolean onScreen: boolean
lastKnownTime: React.RefObject<number | undefined> lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
} }
export class HLSUnsupportedError extends Error { export class HLSUnsupportedError extends Error {
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js' import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen' import {useFullscreen} from '#/components/hooks/useFullscreen'
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
setActive, setActive,
onScreen, onScreen,
lastKnownTime, lastKnownTime,
onPlaybackStart,
}: VideoEmbedInnerWebProps) { }: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
const [isFullscreen] = useFullscreen(containerRef) const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif' const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext() const reportDialogMetadata = useReportDialogMetadataContext()
const playbackStartTrackedRef = useRef(false)
// send error up to error boundary // send error up to error boundary
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
onTimeUpdate={e => { onTimeUpdate={e => {
const currentTime = e.currentTarget.currentTime const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime lastKnownTime.current = currentTime
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(currentTime)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(!focused)
}
if ( if (
!isGif && !isGif &&
reportDialogMetadata && reportDialogMetadata &&
+26 -3
View File
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props { interface Props {
embed: app.bsky.embed.video.View embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
} }
export function VideoEmbed({embed}: Props) { export function VideoEmbed({embed, post}: Props) {
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const renderError = useCallback( const renderError = useCallback(
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
const contents = ( const contents = (
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>
<InnerWrapper embed={embed} /> <InnerWrapper embed={embed} post={post} />
</ErrorBoundary> </ErrorBoundary>
) )
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
) )
} }
function InnerWrapper({embed}: Props) { function InnerWrapper({embed, post}: Props) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null) const ref = useRef<{togglePlayback: () => void}>(null)
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
* the active position cost nothing. * the active position cost nothing.
*/ */
const telemetryRef = useRef<PlaybackTelemetry | null>(null) const telemetryRef = useRef<PlaybackTelemetry | null>(null)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
useEffect(() => { useEffect(() => {
return () => { return () => {
telemetryRef.current?.deactivated() telemetryRef.current?.deactivated()
@@ -121,6 +124,15 @@ function InnerWrapper({embed}: Props) {
setIsActive={active => { setIsActive={active => {
setIsActive(active) setIsActive(active)
if (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) { if (telemetryRef.current == null) {
telemetryRef.current = createPlaybackTelemetry({ telemetryRef.current = createPlaybackTelemetry({
surface: 'feed', surface: 'feed',
@@ -132,6 +144,17 @@ function InnerWrapper({embed}: Props) {
telemetryRef.current?.deactivated() 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 => { onError={error => {
telemetryRef.current?.error(error) telemetryRef.current?.error(error)
ax.metric('video:playback:failed', { ax.metric('video:playback:failed', {
@@ -37,7 +37,13 @@ const noop = () => {}
*/ */
const MIN_CARD_WIDTH = 280 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 t = useTheme()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { const {
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
currentActiveView, currentActiveView,
} = useActiveVideoWeb() } = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false) const [onScreen, setOnScreen] = useState(false)
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
const [isFullscreen] = useFullscreen() const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined) const lastKnownTime = useRef<number | undefined>(undefined)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
const ax = useAnalytics()
const isGif = embed.presentation === 'gif' const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system // GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext const active = isGif || activeFromContext
useEffect(() => {
if (!meaningfullyVisible || 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, meaningfullyVisible, post?.author.did, post?.uri])
useEffect(() => { useEffect(() => {
if (!ref.current) return if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -62,6 +83,9 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const entry = entries[0] const entry = entries[0]
if (!entry) return if (!entry) return
setOnScreen(entry.isIntersecting) setOnScreen(entry.isIntersecting)
setMeaningfullyVisible(
entry.isIntersecting && entry.intersectionRatio >= 0.5,
)
// GIFs don't send position - they don't compete to be the active video // GIFs don't send position - they don't compete to be the active video
if (!isGif) { if (!isGif) {
sendPosition( sendPosition(
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
setActive={setActive} setActive={setActive}
onScreen={onScreen} onScreen={onScreen}
lastKnownTime={lastKnownTime} 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> </OnlyNearScreen>
</ErrorBoundary> </ErrorBoundary>
+2 -1
View File
@@ -134,6 +134,7 @@ function MediaEmbed({
<ExternalEmbed <ExternalEmbed
link={embed.view.external} link={embed.view.external}
onOpen={rest.onOpen} onOpen={rest.onOpen}
post={rest.post}
style={[a.mt_sm, rest.style]} style={[a.mt_sm, rest.style]}
/> />
</ContentHider> </ContentHider>
@@ -144,7 +145,7 @@ function MediaEmbed({
<ContentHider <ContentHider
modui={rest.moderation?.ui('contentMedia')} modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}> activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} /> <VideoEmbed embed={embed.view} post={rest.post} />
</ContentHider> </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_album'
| 'bandcamp_track' | '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> = { export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube', youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts', youtubeShorts: 'YouTube Shorts',
+36 -1
View File
@@ -44,6 +44,7 @@ import {HITSLOP_20} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import { import {
createPlaybackTelemetry, createPlaybackTelemetry,
type PlaybackTelemetry, type PlaybackTelemetry,
@@ -492,9 +493,19 @@ let VideoItem = ({
const {width, height} = useSafeAreaFrame() const {width, height} = useSafeAreaFrame()
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
const hasTrackedView = useRef(false) const hasTrackedView = useRef(false)
const hasTrackedVideoImpression = useRef(false)
useEffect(() => { useEffect(() => {
if (active) { 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({ sendInteraction({
item: post.uri, item: post.uri,
event: 'app.bsky.feed.defs#interactionSeen', event: 'app.bsky.feed.defs#interactionSeen',
@@ -519,6 +530,7 @@ let VideoItem = ({
active, active,
post.uri, post.uri,
post.author.did, post.author.did,
embed.presentation,
feedContext, feedContext,
reqId, reqId,
sendInteraction, sendInteraction,
@@ -557,7 +569,12 @@ let VideoItem = ({
<> <>
<VideoItemPlaceholder embed={embed} /> <VideoItemPlaceholder embed={embed} />
{shouldRenderVideo && player && ( {shouldRenderVideo && player && (
<VideoItemInner player={player} embed={embed} active={active} /> <VideoItemInner
player={player}
embed={embed}
post={post}
active={active}
/>
)} )}
{moderation && ( {moderation && (
<Overlay <Overlay
@@ -587,16 +604,20 @@ VideoItem = memo(VideoItem)
function VideoItemInner({ function VideoItemInner({
player, player,
embed, embed,
post,
active, active,
}: { }: {
player: VideoPlayer player: VideoPlayer
embed: app.bsky.embed.video.View embed: app.bsky.embed.video.View
post: app.bsky.feed.defs.PostView
active: boolean active: boolean
}) { }) {
const {bottom} = useSafeAreaInsets() const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID) const [isReady, setIsReady] = useState(!IS_ANDROID)
const reportDialogMetadata = const reportDialogMetadata =
ReportDialogMetadataContext.useReportDialogMetadataContext() ReportDialogMetadataContext.useReportDialogMetadataContext()
const ax = useAnalytics()
const playbackStartTrackedRef = useRef(false)
usePlaybackTelemetry({player, active, playlist: embed.playlist}) usePlaybackTelemetry({player, active, playlist: embed.playlist})
@@ -617,6 +638,20 @@ function VideoItemInner({
) { ) {
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime 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 ( return (