Add video playback analytics events (#11629)
This commit is contained in:
@@ -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,9 +23,10 @@ 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) {
|
||||
export function VideoEmbed({embed, post}: Props) {
|
||||
const [key, setKey] = useState(0)
|
||||
|
||||
const renderError = useCallback(
|
||||
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
|
||||
|
||||
const contents = (
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<InnerWrapper embed={embed} />
|
||||
<InnerWrapper embed={embed} post={post} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
||||
currentActiveView,
|
||||
} = useActiveVideoWeb()
|
||||
const [onScreen, setOnScreen] = useState(false)
|
||||
const [meaningfullyVisible, setMeaningfullyVisible] = 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 (!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(() => {
|
||||
if (!ref.current) 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]
|
||||
if (!entry) return
|
||||
setOnScreen(entry.isIntersecting)
|
||||
setMeaningfullyVisible(
|
||||
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 +203,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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user