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