Add video playback analytics events
This commit is contained in:
committed by
Samuel Newman
parent
7d39aa3422
commit
41c31abfd4
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user