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
|
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 &&
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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}: Props) {
|
||||||
@@ -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 {
|
||||||
@@ -49,11 +55,25 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
const [onScreen, setOnScreen] = useState(false)
|
const [onScreen, setOnScreen] = 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 (!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(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
if (isFullscreen && !IS_WEB_FIREFOX) return
|
if (isFullscreen && !IS_WEB_FIREFOX) return
|
||||||
@@ -61,7 +81,7 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
entries => {
|
entries => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
if (!entry) return
|
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
|
// GIFs don't send position - they don't compete to be the active video
|
||||||
if (!isGif) {
|
if (!isGif) {
|
||||||
sendPosition(
|
sendPosition(
|
||||||
@@ -179,6 +199,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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_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',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user