forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 Gesture,
5 GestureDetector,
6 type NativeGesture,
7} from 'react-native-gesture-handler'
8import Animated, {
9 clamp,
10 interpolate,
11 runOnJS,
12 runOnUI,
13 type SharedValue,
14 useAnimatedReaction,
15 useAnimatedStyle,
16 useSharedValue,
17 withTiming,
18} from 'react-native-reanimated'
19import {
20 useSafeAreaFrame,
21 useSafeAreaInsets,
22} from 'react-native-safe-area-context'
23import {useEventListener} from 'expo'
24import {type VideoPlayer} from 'expo-video'
25
26import {tokens} from '#/alf'
27import {atoms as a} from '#/alf'
28import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
29import {Text} from '#/components/Typography'
30
31// magic number that is roughly the min height of the write reply button
32// we inset the video by this amount
33export const VIDEO_PLAYER_BOTTOM_INSET = 57
34
35export function Scrubber({
36 active,
37 player,
38 seekingAnimationSV,
39 scrollGesture,
40 children,
41}: {
42 active: boolean
43 player?: VideoPlayer
44 seekingAnimationSV: SharedValue<number>
45 scrollGesture: NativeGesture
46 children?: React.ReactNode
47}) {
48 const {width: screenWidth} = useSafeAreaFrame()
49 const insets = useSafeAreaInsets()
50 const currentTimeSV = useSharedValue(0)
51 const durationSV = useSharedValue(0)
52 const [currentSeekTime, setCurrentSeekTime] = useState(0)
53 const [duration, setDuration] = useState(0)
54
55 const updateTime = (currentTime: number, duration: number) => {
56 'worklet'
57 currentTimeSV.set(currentTime)
58 if (duration !== 0) {
59 durationSV.set(duration)
60 }
61 }
62
63 const isSeekingSV = useSharedValue(false)
64 const seekProgressSV = useSharedValue(0)
65
66 useAnimatedReaction(
67 () => Math.round(seekProgressSV.get()),
68 (progress, prevProgress) => {
69 if (progress !== prevProgress) {
70 runOnJS(setCurrentSeekTime)(progress)
71 }
72 },
73 )
74
75 const seekBy = useCallback(
76 (time: number) => {
77 player?.seekBy(time)
78
79 setTimeout(() => {
80 runOnUI(() => {
81 'worklet'
82 isSeekingSV.set(false)
83 seekingAnimationSV.set(withTiming(0, {duration: 500}))
84 })()
85 }, 50)
86 },
87 [player, isSeekingSV, seekingAnimationSV],
88 )
89
90 const scrubPanGesture = useMemo(() => {
91 return Gesture.Pan()
92 .blocksExternalGesture(scrollGesture)
93 .activeOffsetX([-10, 10])
94 .failOffsetY([-10, 10])
95 .onStart(() => {
96 'worklet'
97 seekProgressSV.set(currentTimeSV.get())
98 isSeekingSV.set(true)
99 seekingAnimationSV.set(withTiming(1, {duration: 500}))
100 })
101 .onUpdate(evt => {
102 'worklet'
103 const progress = evt.x / screenWidth
104 seekProgressSV.set(
105 clamp(progress * durationSV.get(), 0, durationSV.get()),
106 )
107 })
108 .onEnd(evt => {
109 'worklet'
110 isSeekingSV.get()
111
112 const progress = evt.x / screenWidth
113 const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
114
115 // optimisically set the progress bar
116 seekProgressSV.set(newTime)
117
118 // it's seek by, so offset by the current time
119 // seekBy sets isSeekingSV back to false, so no need to do that here
120 runOnJS(seekBy)(newTime - currentTimeSV.get())
121 })
122 }, [
123 scrollGesture,
124 seekingAnimationSV,
125 seekBy,
126 screenWidth,
127 currentTimeSV,
128 durationSV,
129 isSeekingSV,
130 seekProgressSV,
131 ])
132
133 const timeStyle = useAnimatedStyle(() => {
134 return {
135 display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
136 opacity: seekingAnimationSV.get(),
137 }
138 })
139
140 const barStyle = useAnimatedStyle(() => {
141 const currentTime = isSeekingSV.get()
142 ? seekProgressSV.get()
143 : currentTimeSV.get()
144 const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
145 const isSeeking = seekingAnimationSV.get()
146 return {
147 height: isSeeking * 3 + 1,
148 opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
149 width: `${progress * 100}%`,
150 }
151 })
152 const trackStyle = useAnimatedStyle(() => {
153 return {
154 height: seekingAnimationSV.get() * 3 + 1,
155 }
156 })
157 const childrenStyle = useAnimatedStyle(() => {
158 return {
159 opacity: 1 - seekingAnimationSV.get(),
160 }
161 })
162
163 return (
164 <>
165 {player && active && (
166 <PlayerListener
167 player={player}
168 setDuration={setDuration}
169 updateTime={updateTime}
170 />
171 )}
172 <Animated.View
173 style={[
174 a.absolute,
175 {
176 left: 0,
177 right: 0,
178 bottom: insets.bottom + 80,
179 },
180 timeStyle,
181 ]}
182 pointerEvents="none">
183 <Text style={[a.text_center, a.font_semi_bold]}>
184 <Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
185 {formatTime(currentSeekTime)}
186 </Text>
187 <Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
188 <Text
189 style={[
190 a.text_5xl,
191 {opacity: 0.8},
192 {fontVariant: ['tabular-nums']},
193 ]}>
194 {formatTime(duration)}
195 </Text>
196 </Text>
197 </Animated.View>
198
199 <GestureDetector gesture={scrubPanGesture}>
200 <View
201 style={[
202 a.relative,
203 a.w_full,
204 a.justify_end,
205 {
206 paddingBottom: insets.bottom,
207 minHeight:
208 // bottom padding
209 insets.bottom +
210 // scrubber height
211 tokens.space.lg +
212 // write reply height
213 VIDEO_PLAYER_BOTTOM_INSET,
214 },
215 a.z_10,
216 ]}>
217 <View style={[a.w_full, a.relative]}>
218 <Animated.View
219 style={[
220 a.w_full,
221 {backgroundColor: 'white', opacity: 0.2},
222 trackStyle,
223 ]}
224 />
225 <Animated.View
226 style={[
227 a.absolute,
228 {top: 0, left: 0, backgroundColor: 'white'},
229 barStyle,
230 ]}
231 />
232 </View>
233 <Animated.View
234 style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
235 {children}
236 </Animated.View>
237 </View>
238 </GestureDetector>
239 </>
240 )
241}
242
243function PlayerListener({
244 player,
245 setDuration,
246 updateTime,
247}: {
248 player: VideoPlayer
249 setDuration: (duration: number) => void
250 updateTime: (currentTime: number, duration: number) => void
251}) {
252 useEventListener(player, 'timeUpdate', evt => {
253 const duration = player.duration
254 if (duration !== 0) {
255 setDuration(Math.round(duration))
256 }
257 runOnUI(updateTime)(evt.currentTime, duration)
258 })
259
260 return null
261}