Files
bsky-social-app/src/components/Post/Embed/VideoEmbed/index.web.tsx
T
Samuel Newman e7d349bc30 finish phase 3: type-only codemod and zero non-bridge @atproto/api imports
Task 8: codemod repointed 150 namespace-type files to #/lexicons
(AppBskyFeedDefs.PostView -> app.bsky.feed.defs.PostView, .Record -> .Main)
plus ~105 hand-fixed consumers (composer/video state, DebugMod, Profile,
onboarding, dialogs). DM_SERVICE_HEADERS deleted; last chat call sites on
the chat client. RichText.tsx flipped to SDK-only. Remaining @atproto/api
importers: the three session bridge files, the deliberate dual-world
matcher in lib/xrpc-error.ts, and test fixtures. toLex boundary casts are
TODO(phase4)-tagged for bridge removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:15:22 +03:00

346 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
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 {type app} from '#/lexicons'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {}
/**
* Minimum card width for the overlay controls (play, time, CC, volume,
* fullscreen) to fit without crowding. Narrower cards fall back to the
* full-width pillarbox.
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {
active: activeFromContext,
setActive,
sendPosition,
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(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) => (
<VideoError embed={embed} error={error} retry={() => setKey(key + 1)} />
),
[key, embed],
)
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 = (
<div
ref={ref}
style={{
display: 'flex',
flex: 1,
cursor: 'default',
position: 'relative',
backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
onClick={evt => evt.stopPropagation()}>
{fullBleed && embed.thumbnail && (
<>
{/* blurred backdrop fills the bars when the video is boxed */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(32px)',
// hide the transparent fade the blur creates at the edges
transform: 'scale(1.2)',
}}
/>
{/* redraw the sharp thumbnail above the blur */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
</>
)}
<ErrorBoundary renderError={renderError} key={key}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
embed={embed}
active={active}
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
/>
</OnlyNearScreen>
</ErrorBoundary>
</div>
)
return (
<View
style={[a.pt_xs]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
<ViewportObserver
sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage
fullBleed={fullBleed}
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
<MediaInsetBorder />
</ConstrainedImage>
</ViewportObserver>
</View>
)
}
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<HTMLDivElement>(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 (
<View style={[a.flex_1, a.flex_row]}>
<NearScreenContext.Provider value={nearScreen}>
{children}
</NearScreenContext.Provider>
<div
ref={ref}
style={{
// Don't escape bounds when in a message
...(isWithinMessage
? {top: 0, height: '100%'}
: {top: 'calc(50% - 50vh)', height: '100vh'}),
position: 'absolute',
left: '50%',
width: 1,
pointerEvents: 'none',
}}
/>
</View>
)
}
/**
* 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: app.bsky.embed.video.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 (
<VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text>
{showRetryButton && <VideoFallback.RetryButton onPress={retry} />}
</VideoFallback.Container>
)
}