Bluesky app fork with some witchin' additions 💫
witchsky.app
bluesky
fork
client
1import {useCallback, useRef, useState} from 'react'
2import {ActivityIndicator, View} from 'react-native'
3import {ImageBackground} from 'expo-image'
4import {type AppBskyEmbedVideo} from '@atproto/api'
5import {msg} from '@lingui/core/macro'
6import {useLingui} from '@lingui/react'
7import {Trans} from '@lingui/react/macro'
8
9import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
10import {atoms as a, platform} from '#/alf'
11import {Button} from '#/components/Button'
12import {useThrottledValue} from '#/components/hooks/useThrottledValue'
13import {ConstrainedImage} from '#/components/images/AutoSizedImage'
14import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
15import {GifPresentationControls} from './GifPresentationControls'
16import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
17import * as VideoFallback from './VideoEmbedInner/VideoFallback'
18
19interface Props {
20 embed: AppBskyEmbedVideo.View
21}
22
23export function VideoEmbed({embed}: Props) {
24 const [key, setKey] = useState(0)
25
26 const renderError = useCallback(
27 (error: unknown) => (
28 <VideoError error={error} retry={() => setKey(key + 1)} />
29 ),
30 [key],
31 )
32
33 let aspectRatio: number | undefined
34 const dims = embed.aspectRatio
35 if (dims) {
36 aspectRatio = dims.width / dims.height
37 if (Number.isNaN(aspectRatio)) {
38 aspectRatio = undefined
39 }
40 }
41
42 let constrained: number | undefined
43 if (aspectRatio !== undefined) {
44 const ratio = 1 / 2 // max of 1:2 ratio in feeds
45 constrained = Math.max(aspectRatio, ratio)
46 }
47
48 const contents = (
49 <ErrorBoundary renderError={renderError} key={key}>
50 <InnerWrapper embed={embed} />
51 </ErrorBoundary>
52 )
53
54 return (
55 <View style={[a.pt_xs]}>
56 <ConstrainedImage
57 aspectRatio={constrained || 1}
58 // slightly smaller max height than images
59 // images use 16 / 9, for reference
60 minMobileAspectRatio={14 / 9}>
61 {contents}
62 </ConstrainedImage>
63 </View>
64 )
65}
66
67function InnerWrapper({embed}: Props) {
68 const {_} = useLingui()
69 const ref = useRef<{togglePlayback: () => void}>(null)
70
71 const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
72 'pending',
73 )
74 const [isLoading, setIsLoading] = useState(false)
75 const [isActive, setIsActive] = useState(false)
76 const showSpinner = useThrottledValue(isActive && isLoading, 100)
77
78 const showOverlay =
79 !isActive ||
80 isLoading ||
81 (status === 'paused' && !isActive) ||
82 status === 'pending'
83
84 if (!isActive && status !== 'pending') {
85 setStatus('pending')
86 }
87
88 return (
89 <>
90 <VideoEmbedInnerNative
91 embed={embed}
92 setStatus={setStatus}
93 setIsLoading={setIsLoading}
94 setIsActive={setIsActive}
95 ref={ref}
96 />
97 <ImageBackground
98 source={{uri: embed.thumbnail}}
99 accessibilityIgnoresInvertColors
100 style={[
101 a.absolute,
102 a.inset_0,
103 {
104 backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
105 // the play button won't show up on the first render on android 🥴😮💨
106 },
107 platform({
108 android: {display: showOverlay ? 'flex' : 'none'},
109 ios: {zIndex: showOverlay ? 1 : -1},
110 }),
111 ]}
112 cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
113 >
114 {showOverlay &&
115 (embed.presentation === 'gif' ? (
116 <GifPresentationControls
117 isPlaying={false}
118 isLoading={showSpinner}
119 onPress={() => {
120 ref.current?.togglePlayback()
121 }}
122 altText={embed.alt}
123 />
124 ) : (
125 <Button
126 style={[a.flex_1, a.align_center, a.justify_center]}
127 onPress={() => {
128 ref.current?.togglePlayback()
129 }}
130 label={_(msg`Play video`)}>
131 {showSpinner ? (
132 <View style={[a.align_center, a.justify_center]}>
133 <ActivityIndicator size="large" color="white" />
134 </View>
135 ) : (
136 <PlayButtonIcon />
137 )}
138 </Button>
139 ))}
140 </ImageBackground>
141 </>
142 )
143}
144
145function VideoError({retry}: {error: unknown; retry: () => void}) {
146 return (
147 <VideoFallback.Container>
148 <VideoFallback.Text>
149 <Trans>
150 An error occurred while loading the video. Please try again later.
151 </Trans>
152 </VideoFallback.Text>
153 <VideoFallback.RetryButton onPress={retry} />
154 </VideoFallback.Container>
155 )
156}