move scrubber to own file

This commit is contained in:
Samuel Newman
2025-01-18 17:53:08 +00:00
parent 609e4b9bb7
commit 0fb832fe34
2 changed files with 219 additions and 201 deletions
@@ -0,0 +1,216 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
Gesture,
GestureDetector,
NativeGesture,
} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
runOnUI,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useEventListener} from 'expo'
import {VideoPlayer} from 'expo-video'
import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
import {tokens} from '#/alf'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
export function Scrubber({
player,
seekingAnimationSV,
scrollGesture,
}: {
player: VideoPlayer
seekingAnimationSV: SharedValue<number>
scrollGesture: NativeGesture
}) {
const {width: screenWidth} = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const currentTimeSV = useSharedValue(0)
const durationSV = useSharedValue(0)
const [currentSeekTime, setCurrentSeekTime] = useState(0)
const [duration, setDuration] = useState(0)
const updateTime = (currentTime: number, duration: number) => {
'worklet'
currentTimeSV.set(currentTime)
durationSV.set(duration)
}
useEventListener(player, 'timeUpdate', evt => {
runOnUI(updateTime)(evt.currentTime, player.duration)
})
const isSeekingSV = useSharedValue(false)
const seekProgressSV = useSharedValue(0)
useAnimatedReaction(
() => Math.round(seekProgressSV.get()),
(progress, prevProgress) => {
if (progress !== prevProgress) {
runOnJS(setCurrentSeekTime)(progress)
}
},
)
useAnimatedReaction(
() => Math.round(durationSV.get()),
(duration, prevDuration) => {
if (duration !== prevDuration) {
runOnJS(setDuration)(duration)
}
},
)
const seekBy = useCallback(
(time: number) => {
player.seekBy(time)
},
[player],
)
const scrubPanGesture = useMemo(() => {
return Gesture.Pan()
.blocksExternalGesture(scrollGesture)
.activeOffsetX([-1, 1])
.failOffsetY([-10, 10])
.onStart(() => {
'worklet'
seekProgressSV.set(currentTimeSV.get())
isSeekingSV.set(true)
seekingAnimationSV.set(withTiming(1, {duration: 500}))
})
.onUpdate(evt => {
'worklet'
const progress = evt.x / screenWidth
seekProgressSV.set(
clamp(progress * durationSV.get(), 0, durationSV.get()),
)
})
.onEnd(evt => {
'worklet'
isSeekingSV.get()
const progress = evt.x / screenWidth
const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
// it's seek by, so offset by the current time
runOnJS(seekBy)(newTime - currentTimeSV.get())
isSeekingSV.set(false)
seekingAnimationSV.set(withTiming(0, {duration: 500}))
})
}, [
scrollGesture,
seekingAnimationSV,
seekBy,
screenWidth,
currentTimeSV,
durationSV,
isSeekingSV,
seekProgressSV,
])
const timeStyle = useAnimatedStyle(() => {
return {
display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
opacity: seekingAnimationSV.get(),
}
})
const barStyle = useAnimatedStyle(() => {
const currentTime = isSeekingSV.get()
? seekProgressSV.get()
: currentTimeSV.get()
const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
return {
height: seekingAnimationSV.get() * 3 + 1,
width: `${progress * 100}%`,
}
})
return (
<>
<Animated.View
style={[
a.absolute,
{
left: 0,
right: 0,
bottom: insets.bottom + 48,
},
timeStyle,
]}
pointerEvents="none">
<Text style={[a.text_center, a.font_bold]}>
<Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
{formatTime(currentSeekTime)}
</Text>
<Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
<Text
style={[
a.text_5xl,
{opacity: 0.8},
{fontVariant: ['tabular-nums']},
]}>
{formatTime(duration)}
</Text>
</Text>
</Animated.View>
<GestureDetector gesture={scrubPanGesture}>
<View
style={[
a.relative,
a.w_full,
a.justify_end,
{
paddingBottom: insets.bottom,
height:
// bottom padding
insets.bottom +
// actual height
tokens.space.xl,
},
a.z_10,
]}>
<Animated.View style={[{backgroundColor: 'white'}, barStyle]} />
</View>
</GestureDetector>
</>
)
}
/**
* Magic number that matches the Scrubber height
*/
export function ScrubberPlaceholder() {
const {bottom} = useSafeAreaInsets()
return (
<View
style={[
a.w_full,
{
// same as Scrubber
height: bottom + tokens.space.xl,
},
]}
/>
)
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
+3 -201
View File
@@ -13,20 +13,15 @@ import {
NativeGesture,
} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
runOnUI,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
SafeAreaView,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useEvent, useEventListener} from 'expo'
import {useEvent} from 'expo'
import {Image, ImageStyle} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video'
@@ -73,10 +68,9 @@ import {useSetMinimalShellMode} from '#/state/shell'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
import {List} from '#/view/com/util/List'
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Header} from '#/screens/VideoFeed/components/Header'
import {atoms as a, platform, ThemeProvider, tokens, useTheme} from '#/alf'
import {atoms as a, platform, ThemeProvider, useTheme} from '#/alf'
import {setNavigationBar} from '#/alf/util/navigationBar'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
@@ -85,6 +79,7 @@ import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {Scrubber, ScrubberPlaceholder} from './components/Scrubber'
function createThreeVideoPlayers(
sources?: [string, string, string],
@@ -574,7 +569,6 @@ function Overlay({
variant="outline"
color="secondary_inverted"
style={[a.mb_xs, a.bg_transparent]}
hoverStyle={[]}
onPress={() =>
profile.viewer?.following ? queueUnfollow() : queueFollow()
}>
@@ -646,193 +640,6 @@ function Overlay({
)
}
/**
* Magic number that matches the Scrubber height
*/
function ScrubberPlaceholder() {
const {bottom} = useSafeAreaInsets()
return (
<View
style={[
a.w_full,
{
// same as Scrubber
height: bottom + tokens.space.xl,
},
]}
/>
)
}
function Scrubber({
player,
seekingAnimationSV,
scrollGesture,
}: {
player: VideoPlayer
seekingAnimationSV: SharedValue<number>
scrollGesture: NativeGesture
}) {
const {width: screenWidth} = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const currentTimeSV = useSharedValue(0)
const durationSV = useSharedValue(0)
const [currentSeekTime, setCurrentSeekTime] = useState(0)
const [duration, setDuration] = useState(0)
const updateTime = (currentTime: number, duration: number) => {
'worklet'
currentTimeSV.set(currentTime)
durationSV.set(duration)
}
useEventListener(player, 'timeUpdate', evt => {
runOnUI(updateTime)(evt.currentTime, player.duration)
})
const isSeekingSV = useSharedValue(false)
const seekProgressSV = useSharedValue(0)
useAnimatedReaction(
() => Math.round(seekProgressSV.get()),
(progress, prevProgress) => {
if (progress !== prevProgress) {
runOnJS(setCurrentSeekTime)(progress)
}
},
)
useAnimatedReaction(
() => Math.round(durationSV.get()),
(duration, prevDuration) => {
if (duration !== prevDuration) {
runOnJS(setDuration)(duration)
}
},
)
const seekBy = useCallback(
(time: number) => {
player.seekBy(time)
},
[player],
)
const scrubPanGesture = useMemo(() => {
return Gesture.Pan()
.blocksExternalGesture(scrollGesture)
.activeOffsetX([-1, 1])
.failOffsetY([-10, 10])
.onBegin(() => {
'worklet'
})
.onStart(() => {
'worklet'
seekProgressSV.set(currentTimeSV.get())
isSeekingSV.set(true)
seekingAnimationSV.set(withTiming(1, {duration: 500}))
})
.onUpdate(evt => {
'worklet'
const progress = evt.x / screenWidth
seekProgressSV.set(
clamp(progress * durationSV.get(), 0, durationSV.get()),
)
})
.onEnd(evt => {
'worklet'
isSeekingSV.get()
const progress = evt.x / screenWidth
const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
// it's seek by, so offset by the current time
runOnJS(seekBy)(newTime - currentTimeSV.get())
isSeekingSV.set(false)
seekingAnimationSV.set(withTiming(0, {duration: 500}))
})
}, [
scrollGesture,
seekingAnimationSV,
seekBy,
screenWidth,
currentTimeSV,
durationSV,
isSeekingSV,
seekProgressSV,
])
const timeStyle = useAnimatedStyle(() => {
return {
display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
opacity: seekingAnimationSV.get(),
}
})
const barStyle = useAnimatedStyle(() => {
const currentTime = isSeekingSV.get()
? seekProgressSV.get()
: currentTimeSV.get()
const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
return {
height: seekingAnimationSV.get() * 3 + 1,
width: `${progress * 100}%`,
}
})
return (
<>
<Animated.View
style={[
a.absolute,
{
left: 0,
right: 0,
bottom: insets.bottom + 48,
},
timeStyle,
]}
pointerEvents="none">
<Text style={[a.text_center, a.font_bold]}>
<Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
{formatTime(currentSeekTime)}
</Text>
<Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
<Text
style={[
a.text_5xl,
{opacity: 0.8},
{fontVariant: ['tabular-nums']},
]}>
{formatTime(duration)}
</Text>
</Text>
</Animated.View>
<GestureDetector gesture={scrubPanGesture}>
<View
style={[
a.relative,
a.w_full,
a.justify_end,
{
paddingBottom: insets.bottom,
height:
// bottom padding
insets.bottom +
// actual height
tokens.space.xl,
},
a.z_10,
]}>
<Animated.View style={[{backgroundColor: 'white'}, barStyle]} />
</View>
</GestureDetector>
</>
)
}
function ExpandableRichTextView({
value,
authorHandle,
@@ -955,11 +762,6 @@ function PlayPauseTapArea({
)
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
/*
* If the video is taller than 9:16
*/