Compare commits

...

4 Commits

Author SHA1 Message Date
vineyardbovines 06ccfa425f Fix HLS error exports across platforms 2026-07-21 11:28:12 -04:00
vineyardbovines 4bd23313d4 Merge remote-tracking branch 'origin/main' into app-2662-add-client-events-for-unrenderable-video-playback
# Conflicts:
#	src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
2026-07-21 11:24:21 -04:00
vineyardbovines a318d81600 fire web playback failure metric only once 2026-07-16 14:40:01 -04:00
vineyardbovines 8a90310a49 add client event for failed video playback 2026-07-16 12:11:20 -04:00
7 changed files with 110 additions and 6 deletions
+20
View File
@@ -1346,6 +1346,26 @@ export type Events = {
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
/**
* Fired when a video fails terminally during playback: unreachable (404),
* undecodable, or the client lacks the required codecs. Complements the
* Sentry-only video.playback spans with a countable, unsampled event.
*/
'video:playback:failed': {
surface: 'feed' | 'immersiveFeed'
presentation: 'video' | 'gif'
/**
* Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an
* hls.js error details code (e.g. bufferAppendError), or PlayerError on
* native.
*/
errorClass: string
/** Truncated to 256 chars */
errorMessage: string
/** HLS playlist URL, identifies the exact video for server-side lookup */
playlist: string
}
// === 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.
@@ -1,6 +1,7 @@
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
super('Video not found')
}
}
/**
* Fatal hls.js playback error. `detail` is the hls.js error details code
* (e.g. bufferAppendError), which buckets failures more usefully than the
* error message.
*/
export class HLSFatalError extends Error {
detail: string
constructor(detail: string, cause: Error) {
super(cause.message, {cause})
this.detail = detail
}
}
@@ -10,6 +10,7 @@ import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate'
import {
HLSFatalError,
HLSUnsupportedError,
type VideoEmbedInnerWebProps,
VideoNotFoundError,
@@ -17,6 +18,7 @@ import {
import {Controls} from './web-controls/VideoControls'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -306,7 +308,7 @@ function useHLS({
) {
setError(new VideoNotFoundError())
} else {
setError(data.error)
setError(new HLSFatalError(data.details, data.error))
}
} else {
console.error(data.error)
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
errorClass: 'PlayerError',
errorMessage: error.slice(0, 256),
playlist: embed.playlist,
})
}}
ref={ref}
/>
@@ -18,10 +18,12 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
HLSFatalError,
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useAnalytics} from '#/analytics'
import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -69,9 +71,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
<VideoError embed={embed} error={error} retry={() => setKey(key + 1)} />
),
[key],
[key, embed],
)
let aspectRatio: number | undefined
@@ -222,23 +224,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
return nearScreen ? children : null
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
function VideoError({
embed,
error,
retry,
}: {
embed: AppBskyEmbedVideo.View
error: unknown
retry: () => void
}) {
const {_} = useLingui()
const ax = useAnalytics()
let showRetryButton = true
let text = null
let errorClass: string
if (error instanceof VideoNotFoundError) {
text = _(msg`Video not found.`)
errorClass = 'VideoNotFoundError'
} else if (error instanceof HLSUnsupportedError) {
showRetryButton = false
text = _(
msg`This video cant be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC).`,
)
errorClass = 'HLSUnsupportedError'
} else {
text = _(msg`An error occurred while loading the video. Please try again.`)
if (error instanceof HLSFatalError) {
errorClass = error.detail
} else if (error instanceof Error) {
errorClass = error.name || 'Error'
} else {
errorClass = 'Unknown'
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
const presentation = embed.presentation === 'gif' ? 'gif' : 'video'
const playlist = embed.playlist
/*
* Fire exactly once per failure - the analytics context identity can change
* (session/geolocation updates) while this fallback stays mounted, which
* would otherwise re-run the effect and double-count.
*/
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
ax.metric('video:playback:failed', {
surface: 'feed',
presentation,
errorClass,
errorMessage: errorMessage.slice(0, 256),
playlist,
})
}, [ax, presentation, playlist, errorClass, errorMessage])
return (
<VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text>
+19 -2
View File
@@ -588,7 +588,7 @@ function VideoItemInner({
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
usePlaybackTelemetry({player, active})
usePlaybackTelemetry({player, active, playlist: embed.playlist})
useEventListener(player, 'timeUpdate', evt => {
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
@@ -625,10 +625,13 @@ function VideoItemInner({
function usePlaybackTelemetry({
player,
active,
playlist,
}: {
player: VideoPlayer
active: boolean
playlist: string
}) {
const ax = useAnalytics()
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
useEffect(() => {
@@ -652,7 +655,21 @@ function usePlaybackTelemetry({
if (evt.status === 'readyToPlay') {
telemetryRef.current?.ready()
} else if (evt.status === 'error') {
telemetryRef.current?.error(evt.error?.message ?? 'unknown')
const message = evt.error?.message ?? 'unknown'
telemetryRef.current?.error(message)
/*
* Adjacent players are preloaded and can error before the user ever
* swipes to them - only count failures the user actually sees.
*/
if (active) {
ax.metric('video:playback:failed', {
surface: 'immersiveFeed',
presentation: 'video',
errorClass: 'PlayerError',
errorMessage: message.slice(0, 256),
playlist,
})
}
}
})