Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at d9e37a222a218bc37e7e7e34c8a438f173d54840 261 lines 6.8 kB view raw
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}