import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react' import {View} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, useTheme} from '#/alf' import {useIsWithinMessage} from '#/components/dms/MessageContext' 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 {useVideoDownload} from './useVideoDownload' import * as VideoFallback from './VideoEmbedInner/VideoFallback' const noop = () => {} const MIN_CARD_WIDTH = 280 export function VideoEmbed({ embed, did, }: { embed: AppBskyEmbedVideo.View did?: string }) { const t = useTheme() const onDownload = useVideoDownload({did, cid: embed.cid}) const ref = useRef(null) const { active: activeFromContext, setActive, sendPosition, currentActiveView, } = useActiveVideoWeb() const [onScreen, setOnScreen] = useState(false) const [isFullscreen] = useFullscreen() const lastKnownTime = useRef(undefined) const isGif = embed.presentation === 'gif' // GIFs don't participate in the "one video at a time" system const active = isGif || activeFromContext useEffect(() => { if (!ref.current) return if (isFullscreen && !IS_WEB_FIREFOX) return const observer = new IntersectionObserver( entries => { const entry = entries[0] if (!entry) return setOnScreen(entry.isIntersecting) // GIFs don't send position - they don't compete to be the active video if (!isGif) { sendPosition( entry.boundingClientRect.y + entry.boundingClientRect.height / 2, ) } }, {threshold: 0.5}, ) observer.observe(ref.current) return () => observer.disconnect() }, [sendPosition, isFullscreen, isGif]) const [key, setKey] = useState(0) const renderError = useCallback( (error: unknown) => ( setKey(key + 1)} /> ), [key, embed], ) const getErrorMetadata = useCallback((error: Error) => { if (!(error instanceof HLSFatalError)) return {} return { tags: { hls_error_detail: error.detail, hls_error_type: error.type, }, hls: error.diagnostics, } }, []) let aspectRatio: number | undefined const dims = embed.aspectRatio if (dims) { aspectRatio = dims.width / dims.height if (Number.isNaN(aspectRatio)) { aspectRatio = undefined } } let constrained: number | undefined if (aspectRatio !== undefined) { const ratio = 1 / 2 // max of 1:2 ratio in feeds constrained = Math.max(aspectRatio, ratio) } const [containerWidth, setContainerWidth] = useState(0) /* * Portrait videos render at their own ratio instead of pillarboxed, but * only when the resulting card fits the overlay controls. Videos taller * than 1:2 would still show bars inside a ratio-fit card, and an unknown * ratio can't be fit, so both keep the full-width pillarbox - a narrow * card with black slices down the sides looks broken (see #9371). */ const cardWidth = containerWidth * Math.min(aspectRatio ?? 1, 1) const fullBleed = aspectRatio === undefined || aspectRatio < 1 / 2 || (containerWidth > 0 && cardWidth < MIN_CARD_WIDTH) const contents = (
evt.stopPropagation()}> {fullBleed && embed.thumbnail && ( <> {/* blurred backdrop fills the bars when the video is boxed */}
{/* redraw the sharp thumbnail above the blur */}
)}
) return ( setContainerWidth(e.nativeEvent.layout.width)}> {contents} ) } const NearScreenContext = createContext(false) NearScreenContext.displayName = 'VideoNearScreenContext' /** * Renders a 100vh tall div and watches it with an IntersectionObserver to * send the position of the div when it's near the screen. * * IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container. */ function ViewportObserver({ children, sendPosition, isAnyViewActive, }: { children: React.ReactNode sendPosition: (position: number) => void isAnyViewActive: boolean }) { const ref = useRef(null) const [nearScreen, setNearScreen] = useState(false) const [isFullscreen] = useFullscreen() const isWithinMessage = useIsWithinMessage() // Send position when scrolling. This is done with an IntersectionObserver // observing a div of 100vh height useEffect(() => { if (!ref.current) return if (isFullscreen && !IS_WEB_FIREFOX) return const observer = new IntersectionObserver( entries => { const entry = entries[0] if (!entry) return const position = entry.boundingClientRect.y + entry.boundingClientRect.height / 2 sendPosition(position) setNearScreen(entry.isIntersecting) }, {threshold: Array.from({length: 101}, (_, i) => i / 100)}, ) observer.observe(ref.current) return () => observer.disconnect() }, [sendPosition, isFullscreen]) // In case scrolling hasn't started yet, send up the position useEffect(() => { if (ref.current && !isAnyViewActive) { const rect = ref.current.getBoundingClientRect() const position = rect.y + rect.height / 2 sendPosition(position) } }, [isAnyViewActive, sendPosition]) return ( {children}
) } /** * Awkward data flow here, but we need to hide the video when it's not near the screen. * But also, ViewportObserver _must_ not be within a `overflow: hidden` container. * So we put it at the top level of the component tree here, then hide the children of * the auto-resizing container. */ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => { const nearScreen = useContext(NearScreenContext) return nearScreen ? children : null } 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 can’t 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 ( {text} {showRetryButton && } ) }