diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index f909767156..5aa3b00c5e 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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. diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx index 63c4236aa1..9f47bc8f1e 100644 --- a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx +++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx @@ -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() @@ -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 ( <> diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index a496780524..64940d710c 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -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 hideAlt?: boolean }) => { @@ -120,7 +122,11 @@ export const ExternalEmbed = ({ {embedPlayerParams?.isGif ? ( ) : embedPlayerParams ? ( - + ) : undefined} 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 ( { @@ -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 => { diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index 8c4ef57d20..56fb66f18e 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = { setActive: () => void onScreen: boolean lastKnownTime: React.RefObject + onPlaybackStart: (autoplay: boolean) => void } export class HLSUnsupportedError extends Error { diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 947d4ac3d6..213dbfa911 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -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(null) const videoRef = useRef(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(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 && diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index a67495189a..f2c36e6a43 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -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(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', { diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index a02fff613a..c4aed50c2d 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -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(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(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, + }) + }} /> diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 5e195ac2f9..1468f9b3f5 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -134,6 +134,7 @@ function MediaEmbed({ @@ -144,7 +145,7 @@ function MediaEmbed({ - + ) } diff --git a/src/lib/media/video/__tests__/analytics.test.ts b/src/lib/media/video/__tests__/analytics.test.ts new file mode 100644 index 0000000000..846c8c245b --- /dev/null +++ b/src/lib/media/video/__tests__/analytics.test.ts @@ -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) + }) +}) diff --git a/src/lib/media/video/analytics.ts b/src/lib/media/video/analytics.ts new file mode 100644 index 0000000000..2bae36f4f8 --- /dev/null +++ b/src/lib/media/video/analytics.ts @@ -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 + ) +} diff --git a/src/lib/strings/embed-player.test.ts b/src/lib/strings/embed-player.test.ts new file mode 100644 index 0000000000..c46fea6da8 --- /dev/null +++ b/src/lib/strings/embed-player.test.ts @@ -0,0 +1,20 @@ +import {type EmbedPlayerType, getEmbedPlayerMediaType} from './embed-player' + +describe('getEmbedPlayerMediaType', () => { + it.each< + readonly [EmbedPlayerType, ReturnType] + >([ + ['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) + }) +}) diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 2bba205193..a580beb6d7 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -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 = { youtube: 'YouTube', youtubeShorts: 'YouTube Shorts', diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index cb43845b3a..9c662fdf13 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -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 = ({ <> {shouldRenderVideo && player && ( - + )} {moderation && (